mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-04 14:52:05 +00:00
Initial commit: after-hours shift manager Slack bot
Slack Bolt app on Lambda for managing on-call shifts. Employees can pick up, drop, and swap shifts via /oncall commands. Changes update 3CX ring group 800 routing in real time for same-day shifts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
4d0cdb5cfb
12 changed files with 1020 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.aws-sam/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
samconfig.toml
|
||||||
|
output.json
|
||||||
72
README.md
Normal file
72
README.md
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Slack Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `/oncall` | Show this week's schedule |
|
||||||
|
| `/oncall next` | Show next week's schedule |
|
||||||
|
| `/oncall pick <date>` | Pick up an available shift |
|
||||||
|
| `/oncall drop <date>` | Drop your shift (marks it available) |
|
||||||
|
| `/oncall swap <date> @person` | Hand your shift to someone else |
|
||||||
|
| `/oncall register <ext>` | Link your Slack account to your phone extension |
|
||||||
|
| `/oncall roster` | Show all employees and their link status |
|
||||||
|
| `/oncall help` | Show help |
|
||||||
|
|
||||||
|
Dates accept: `today`, `tomorrow`, `monday`-`sunday`, `4/5`, `2026-04-05`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Runtime**: Python 3.12 on AWS Lambda (via API Gateway)
|
||||||
|
- **Data**: DynamoDB single-table (`afterhours-shifts`)
|
||||||
|
- **IaC**: AWS SAM (`template.yaml`)
|
||||||
|
- **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
|
||||||
|
|
||||||
|
### DynamoDB Schema
|
||||||
|
|
||||||
|
Single table with `PK` / `SK` keys:
|
||||||
|
|
||||||
|
| PK | SK | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `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`) |
|
||||||
|
| `CONFIG` | `CONFIG` | Settings: ring_group, fallback_extension |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
See [SETUP.md](SETUP.md) for full deployment and Slack app creation instructions.
|
||||||
|
|
||||||
|
Quick reference:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and deploy
|
||||||
|
sam build
|
||||||
|
sam deploy --guided --stack-name afterhours-shift-manager --region us-east-1
|
||||||
|
|
||||||
|
# Seed initial schedule data
|
||||||
|
python scripts/seed_schedule.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [3cx-ring-scheduler](https://github.com/Sea-Haven-Industries/3cx-ring-scheduler) — the Lambda that updates 3CX ring group routing daily
|
||||||
95
SETUP.md
Normal file
95
SETUP.md
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
# After-Hours Shift Manager — Setup Guide
|
||||||
|
|
||||||
|
## 1. Create a Slack App
|
||||||
|
|
||||||
|
1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From scratch**
|
||||||
|
2. Name: `After-Hours Shift Manager`, pick your workspace
|
||||||
|
3. Under **OAuth & Permissions**, add these **Bot Token Scopes**:
|
||||||
|
- `chat:write` — post messages to channels
|
||||||
|
- `commands` — register slash commands
|
||||||
|
- `chat:write.public` — post to channels the bot isn't in
|
||||||
|
4. Under **Slash Commands**, create a new command:
|
||||||
|
- Command: `/oncall`
|
||||||
|
- Request URL: `(fill in after deploy — see step 4)`
|
||||||
|
- Short Description: `Manage after-hours on-call shifts`
|
||||||
|
- Usage Hint: `[schedule | pick <date> | drop <date> | swap <date> @person | register <ext> | roster | help]`
|
||||||
|
5. Under **Interactivity & Shortcuts**, toggle **ON**:
|
||||||
|
- Request URL: same URL as the slash command (the `/slack/events` endpoint)
|
||||||
|
6. **Install to Workspace** — approve the permissions
|
||||||
|
7. Copy the **Bot User OAuth Token** (`xoxb-...`) and **Signing Secret** (under Basic Information)
|
||||||
|
|
||||||
|
## 2. Store Secrets in SSM Parameter Store
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aws ssm put-parameter \
|
||||||
|
--name "/afterhours-shift-manager/slack-bot-token" \
|
||||||
|
--type SecureString \
|
||||||
|
--value "xoxb-YOUR-BOT-TOKEN"
|
||||||
|
|
||||||
|
aws ssm put-parameter \
|
||||||
|
--name "/afterhours-shift-manager/slack-signing-secret" \
|
||||||
|
--type SecureString \
|
||||||
|
--value "YOUR-SIGNING-SECRET"
|
||||||
|
|
||||||
|
# Channel ID where the bot will post weekly schedules
|
||||||
|
# (right-click channel in Slack → Copy link → the ID is the last segment)
|
||||||
|
aws ssm put-parameter \
|
||||||
|
--name "/afterhours-shift-manager/channel-id" \
|
||||||
|
--type SecureString \
|
||||||
|
--value "C0XXXXXXX"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Deploy the Stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and deploy
|
||||||
|
sam build
|
||||||
|
sam deploy --guided --stack-name afterhours-shift-manager --region us-east-1
|
||||||
|
|
||||||
|
# Note the SlackBotApiUrl output — you'll need it for step 4
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Set the Slack Request URL
|
||||||
|
|
||||||
|
After deploy, copy the `SlackBotApiUrl` from the SAM output. Go back to your Slack app settings:
|
||||||
|
|
||||||
|
- **Slash Commands** → edit `/oncall` → set **Request URL** to the output URL
|
||||||
|
- **Interactivity & Shortcuts** → set **Request URL** to the same URL
|
||||||
|
|
||||||
|
## 5. Seed the Schedule
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/seed_schedule.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This populates the DynamoDB table with the employee roster and default weekly schedule.
|
||||||
|
|
||||||
|
## 6. Invite the Bot & Register Users
|
||||||
|
|
||||||
|
1. Create a channel (e.g. `#after-hours-shifts`) and invite the bot: `/invite @After-Hours Shift Manager`
|
||||||
|
2. Each employee links their Slack account by running:
|
||||||
|
```
|
||||||
|
/oncall register 114
|
||||||
|
```
|
||||||
|
(using their own extension number)
|
||||||
|
|
||||||
|
## 7. Update the 3CX Scheduler (optional)
|
||||||
|
|
||||||
|
To have the 3CX scheduler read overrides from DynamoDB (so Slack-driven changes apply to future dates automatically), deploy the updated 3CX scheduler from the `feature/dynamodb-shift-integration` branch. See that branch's changes for details.
|
||||||
|
|
||||||
|
Without this step, the Slack bot still works — it invokes the 3CX scheduler Lambda directly for same-day changes. Future-date overrides would only take effect if the scheduler reads DynamoDB.
|
||||||
|
|
||||||
|
## Commands Reference
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `/oncall` | Show this week's schedule |
|
||||||
|
| `/oncall next` | Show next week's schedule |
|
||||||
|
| `/oncall pick <date>` | Pick up a shift |
|
||||||
|
| `/oncall drop <date>` | Drop your shift (marks it open) |
|
||||||
|
| `/oncall swap <date> @person` | Hand your shift to someone else |
|
||||||
|
| `/oncall register <ext>` | Link your Slack to your extension |
|
||||||
|
| `/oncall roster` | Show all employees and their link status |
|
||||||
|
| `/oncall help` | Show help |
|
||||||
|
|
||||||
|
Dates can be: `today`, `tomorrow`, `monday`–`sunday`, `4/5`, `2026-04-05`
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
slack_bolt>=1.18.0,<2.0
|
||||||
|
boto3>=1.28.0
|
||||||
89
scripts/seed_schedule.py
Normal file
89
scripts/seed_schedule.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seed the afterhours-shifts DynamoDB table with initial roster and schedule.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/seed_schedule.py # uses default table name
|
||||||
|
python scripts/seed_schedule.py --table my-table # custom table name
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
|
||||||
|
TABLE_NAME = "afterhours-shifts"
|
||||||
|
|
||||||
|
ROSTER = [
|
||||||
|
{"extension": "100", "name": "Adam Moussa"},
|
||||||
|
{"extension": "111", "name": "Alyssa Ficarra"},
|
||||||
|
{"extension": "113", "name": "Sarah May"},
|
||||||
|
{"extension": "114", "name": "Ashley Fedner"},
|
||||||
|
{"extension": "115", "name": "Derrick Smith"},
|
||||||
|
{"extension": "116", "name": "Cindy Vallecillo"},
|
||||||
|
]
|
||||||
|
|
||||||
|
WEEKLY_SCHEDULE = {
|
||||||
|
"Monday": {"extension": "114", "name": "Ashley Fedner"},
|
||||||
|
"Tuesday": {"extension": "115", "name": "Derrick Smith"},
|
||||||
|
"Wednesday": {"extension": "114", "name": "Ashley Fedner"},
|
||||||
|
"Thursday": {"extension": "115", "name": "Derrick Smith"},
|
||||||
|
"Friday": {"extension": "116", "name": "Cindy Vallecillo"},
|
||||||
|
"Saturday": {"extension": "100", "name": "Fallback"},
|
||||||
|
"Sunday": {"extension": "100", "name": "Fallback"},
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIG = {
|
||||||
|
"ring_group": "800",
|
||||||
|
"fallback_extension": "100",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def seed(table_name: str):
|
||||||
|
dynamodb = boto3.resource("dynamodb")
|
||||||
|
table = dynamodb.Table(table_name)
|
||||||
|
|
||||||
|
print(f"Seeding table: {table_name}")
|
||||||
|
|
||||||
|
# Roster
|
||||||
|
for emp in ROSTER:
|
||||||
|
table.put_item(
|
||||||
|
Item={
|
||||||
|
"PK": "ROSTER",
|
||||||
|
"SK": emp["extension"],
|
||||||
|
"name": emp["name"],
|
||||||
|
"extension": emp["extension"],
|
||||||
|
"slack_user_id": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(f" Roster: Ext {emp['extension']} — {emp['name']}")
|
||||||
|
|
||||||
|
# Weekly schedule
|
||||||
|
for day, info in WEEKLY_SCHEDULE.items():
|
||||||
|
table.put_item(
|
||||||
|
Item={
|
||||||
|
"PK": "WEEKLY",
|
||||||
|
"SK": day,
|
||||||
|
"extension": info["extension"],
|
||||||
|
"name": info["name"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(f" Weekly: {day} — {info['name']} (Ext {info['extension']})")
|
||||||
|
|
||||||
|
# Config
|
||||||
|
table.put_item(
|
||||||
|
Item={
|
||||||
|
"PK": "CONFIG",
|
||||||
|
"SK": "CONFIG",
|
||||||
|
**CONFIG,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(f" Config: ring_group={CONFIG['ring_group']}, fallback={CONFIG['fallback_extension']}")
|
||||||
|
|
||||||
|
print("\nDone! Employees should run `/oncall register <extension>` in Slack to link their accounts.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--table", default=TABLE_NAME)
|
||||||
|
args = parser.parse_args()
|
||||||
|
seed(args.table)
|
||||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
295
src/app.py
Normal file
295
src/app.py
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
"""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 (
|
||||||
|
build_help_blocks,
|
||||||
|
build_roster_blocks,
|
||||||
|
build_shift_change_message,
|
||||||
|
build_week_schedule,
|
||||||
|
)
|
||||||
|
from src.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
|
||||||
|
DAY_NAMES = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(text: str) -> datetime | None:
|
||||||
|
"""Parse flexible date input: today, tomorrow, day names, m/d, YYYY-MM-DD."""
|
||||||
|
now = datetime.now(EASTERN)
|
||||||
|
text = text.strip().lower()
|
||||||
|
|
||||||
|
if text == "today":
|
||||||
|
return now
|
||||||
|
if text == "tomorrow":
|
||||||
|
return now + timedelta(days=1)
|
||||||
|
|
||||||
|
if text in DAY_NAMES:
|
||||||
|
target = DAY_NAMES.index(text)
|
||||||
|
current = now.weekday()
|
||||||
|
delta = (target - current) % 7
|
||||||
|
if delta == 0:
|
||||||
|
delta = 7
|
||||||
|
return now + timedelta(days=delta)
|
||||||
|
|
||||||
|
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%m/%d", "%m-%d"):
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(text, fmt)
|
||||||
|
if "%Y" not in fmt:
|
||||||
|
parsed = parsed.replace(year=now.year)
|
||||||
|
if parsed.replace(tzinfo=EASTERN) < now - timedelta(days=1):
|
||||||
|
parsed = parsed.replace(year=now.year + 1)
|
||||||
|
return parsed.replace(tzinfo=EASTERN)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
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")
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_today(date_str: str) -> bool:
|
||||||
|
return date_str == datetime.now(EASTERN).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(bot_token: str, signing_secret: str) -> App:
|
||||||
|
app = App(
|
||||||
|
token=bot_token,
|
||||||
|
signing_secret=signing_secret,
|
||||||
|
process_before_response=True,
|
||||||
|
)
|
||||||
|
schedule = ShiftSchedule()
|
||||||
|
|
||||||
|
# ── /oncall command ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.command("/oncall")
|
||||||
|
def handle_oncall(ack, command, respond, client):
|
||||||
|
ack()
|
||||||
|
text = (command.get("text") or "").strip()
|
||||||
|
user_id = command["user_id"]
|
||||||
|
channel_id = command["channel_id"]
|
||||||
|
|
||||||
|
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())
|
||||||
|
elif text == "roster":
|
||||||
|
respond(blocks=build_roster_blocks(schedule.get_roster()))
|
||||||
|
elif text.startswith("register"):
|
||||||
|
_handle_register(respond, schedule, user_id, text)
|
||||||
|
elif text.startswith("pick"):
|
||||||
|
_handle_pick(respond, schedule, user_id, text, channel_id, client)
|
||||||
|
elif text.startswith("drop"):
|
||||||
|
_handle_drop(respond, schedule, user_id, text, channel_id, client)
|
||||||
|
elif text.startswith("swap"):
|
||||||
|
_handle_swap(respond, schedule, user_id, text, channel_id, client)
|
||||||
|
else:
|
||||||
|
respond(text="Unknown command. Try `/oncall help`")
|
||||||
|
|
||||||
|
# ── Interactive button: pick up open shift ──────────────────────────
|
||||||
|
|
||||||
|
@app.action(re.compile(r"^pickup_"))
|
||||||
|
def handle_pickup_button(ack, body, client):
|
||||||
|
ack()
|
||||||
|
action_id = body["actions"][0]["action_id"]
|
||||||
|
date_str = action_id.replace("pickup_", "")
|
||||||
|
user_id = body["user"]["id"]
|
||||||
|
channel_id = body["channel"]["id"]
|
||||||
|
|
||||||
|
employee = schedule.get_employee_by_slack_id(user_id)
|
||||||
|
if not employee:
|
||||||
|
client.chat_postEphemeral(
|
||||||
|
channel=channel_id,
|
||||||
|
user=user_id,
|
||||||
|
text="You're not registered. Use `/oncall register <extension>` first.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
schedule.set_override(date_str, employee["extension"], employee["name"])
|
||||||
|
|
||||||
|
if is_today(date_str):
|
||||||
|
invoke_3cx_scheduler(employee["extension"])
|
||||||
|
|
||||||
|
blocks = build_shift_change_message(
|
||||||
|
user_id, date_str, "picked_up", employee["extension"], employee["name"]
|
||||||
|
)
|
||||||
|
client.chat_postMessage(channel=channel_id, blocks=blocks, text=f"Shift picked up for {date_str}")
|
||||||
|
|
||||||
|
# ── Subcommand handlers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _show_schedule(respond, schedule):
|
||||||
|
blocks = build_week_schedule(schedule)
|
||||||
|
respond(blocks=blocks)
|
||||||
|
|
||||||
|
def _show_next_week(respond, schedule):
|
||||||
|
now = datetime.now(EASTERN)
|
||||||
|
next_monday = now + timedelta(days=(7 - now.weekday()))
|
||||||
|
blocks = build_week_schedule(schedule, start_date=next_monday)
|
||||||
|
respond(blocks=blocks)
|
||||||
|
|
||||||
|
def _handle_register(respond, schedule, user_id, text):
|
||||||
|
parts = text.split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
respond(text="Usage: `/oncall register <extension>` (e.g. `/oncall register 114`)")
|
||||||
|
return
|
||||||
|
|
||||||
|
ext = parts[1].strip()
|
||||||
|
employee = schedule.register_user(user_id, ext)
|
||||||
|
if not employee:
|
||||||
|
respond(text=f"Extension {ext} not found in the roster. Check `/oncall roster`.")
|
||||||
|
return
|
||||||
|
|
||||||
|
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)
|
||||||
|
if len(parts) < 2:
|
||||||
|
respond(text="Usage: `/oncall pick <date>` (e.g. `/oncall pick friday`)")
|
||||||
|
return
|
||||||
|
|
||||||
|
employee = schedule.get_employee_by_slack_id(user_id)
|
||||||
|
if not employee:
|
||||||
|
respond(text="You're not registered. Use `/oncall register <extension>` first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
date = parse_date(parts[1])
|
||||||
|
if not date:
|
||||||
|
respond(text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 2026-04-05")
|
||||||
|
return
|
||||||
|
|
||||||
|
date_str = date.strftime("%Y-%m-%d")
|
||||||
|
day_name = date.strftime("%A")
|
||||||
|
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:
|
||||||
|
respond(text=f"That shift is already covered by {name} (Ext {ext}). They'd need to drop it first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
schedule.set_override(date_str, employee["extension"], employee["name"])
|
||||||
|
|
||||||
|
if is_today(date_str):
|
||||||
|
invoke_3cx_scheduler(employee["extension"])
|
||||||
|
|
||||||
|
blocks = build_shift_change_message(
|
||||||
|
user_id, date_str, "picked_up", employee["extension"], employee["name"]
|
||||||
|
)
|
||||||
|
client.chat_postMessage(channel=channel_id, blocks=blocks, text=f"Shift picked up for {date_str}")
|
||||||
|
respond(text=f"You picked up the shift for *{date.strftime('%A, %b %-d')}*.")
|
||||||
|
|
||||||
|
def _handle_drop(respond, schedule, user_id, text, channel_id, client):
|
||||||
|
parts = text.split(maxsplit=1)
|
||||||
|
if len(parts) < 2:
|
||||||
|
respond(text="Usage: `/oncall drop <date>` (e.g. `/oncall drop friday`)")
|
||||||
|
return
|
||||||
|
|
||||||
|
employee = schedule.get_employee_by_slack_id(user_id)
|
||||||
|
if not employee:
|
||||||
|
respond(text="You're not registered. Use `/oncall register <extension>` first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
date = parse_date(parts[1])
|
||||||
|
if not date:
|
||||||
|
respond(text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 2026-04-05")
|
||||||
|
return
|
||||||
|
|
||||||
|
date_str = date.strftime("%Y-%m-%d")
|
||||||
|
day_name = date.strftime("%A")
|
||||||
|
ext, name, source = schedule.resolve_shift(date_str, day_name)
|
||||||
|
|
||||||
|
if ext != employee["extension"]:
|
||||||
|
respond(text=f"That's not your shift — it belongs to {name} (Ext {ext}).")
|
||||||
|
return
|
||||||
|
|
||||||
|
schedule.mark_open(date_str)
|
||||||
|
|
||||||
|
if is_today(date_str):
|
||||||
|
invoke_3cx_scheduler(FALLBACK_EXTENSION)
|
||||||
|
|
||||||
|
blocks = build_shift_change_message(user_id, date_str, "dropped", ext, name)
|
||||||
|
client.chat_postMessage(channel=channel_id, blocks=blocks, text=f"Shift dropped for {date_str}")
|
||||||
|
respond(text=f"You dropped the shift for *{date.strftime('%A, %b %-d')}*. It's now open for pickup.")
|
||||||
|
|
||||||
|
def _handle_swap(respond, schedule, user_id, text, channel_id, client):
|
||||||
|
# Expected format: swap <date> @user OR swap <date> <extension>
|
||||||
|
parts = text.split(maxsplit=2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
respond(text="Usage: `/oncall swap <date> @person` (e.g. `/oncall swap friday @sarah`)")
|
||||||
|
return
|
||||||
|
|
||||||
|
employee = schedule.get_employee_by_slack_id(user_id)
|
||||||
|
if not employee:
|
||||||
|
respond(text="You're not registered. Use `/oncall register <extension>` first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
date = parse_date(parts[1])
|
||||||
|
if not date:
|
||||||
|
respond(text=f"Couldn't parse date: `{parts[1]}`.")
|
||||||
|
return
|
||||||
|
|
||||||
|
date_str = date.strftime("%Y-%m-%d")
|
||||||
|
day_name = date.strftime("%A")
|
||||||
|
ext, name, source = schedule.resolve_shift(date_str, day_name)
|
||||||
|
|
||||||
|
if ext != employee["extension"]:
|
||||||
|
respond(text=f"That's not your shift — it belongs to {name} (Ext {ext}). You can only swap your own shifts.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve target user — could be <@U12345> or an extension number
|
||||||
|
target_text = parts[2].strip()
|
||||||
|
slack_id_match = re.match(r"<@(\w+)(?:\|[^>]*)?>", target_text)
|
||||||
|
if slack_id_match:
|
||||||
|
target_slack_id = slack_id_match.group(1)
|
||||||
|
target = schedule.get_employee_by_slack_id(target_slack_id)
|
||||||
|
if not target:
|
||||||
|
respond(text=f"<@{target_slack_id}> isn't registered. They need to run `/oncall register <extension>`.")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
target = schedule.get_employee_by_extension(target_text)
|
||||||
|
if not target:
|
||||||
|
respond(text=f"Extension `{target_text}` not found in the roster.")
|
||||||
|
return
|
||||||
|
|
||||||
|
schedule.set_override(date_str, target["extension"], target["name"])
|
||||||
|
|
||||||
|
if is_today(date_str):
|
||||||
|
invoke_3cx_scheduler(target["extension"])
|
||||||
|
|
||||||
|
blocks = build_shift_change_message(
|
||||||
|
target.get("slack_user_id", user_id),
|
||||||
|
date_str,
|
||||||
|
"swapped",
|
||||||
|
target["extension"],
|
||||||
|
target["name"],
|
||||||
|
)
|
||||||
|
client.chat_postMessage(channel=channel_id, blocks=blocks, text=f"Shift swapped for {date_str}")
|
||||||
|
respond(text=f"Swapped *{date.strftime('%A, %b %-d')}* to {target['name']} (Ext {target['extension']}).")
|
||||||
|
|
||||||
|
return app
|
||||||
126
src/blocks.py
Normal file
126
src/blocks.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
"""Slack Block Kit message builders for shift schedule display."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
|
||||||
|
DAY_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||||
|
|
||||||
|
|
||||||
|
def _format_shift_line(date: datetime, ext: str, name: str, source: str, is_today: bool) -> str:
|
||||||
|
day_label = date.strftime("%a %b %-d")
|
||||||
|
if is_today:
|
||||||
|
day_label = f"*{day_label} (today)*"
|
||||||
|
|
||||||
|
if source == "available":
|
||||||
|
return f":large_yellow_circle: {day_label} — *Available*"
|
||||||
|
if source == "override":
|
||||||
|
return f":large_blue_circle: {day_label} — {name} (Ext {ext}) _[picked up]_"
|
||||||
|
return f":white_circle: {day_label} — {name} (Ext {ext})"
|
||||||
|
|
||||||
|
|
||||||
|
def build_week_schedule(schedule, start_date: datetime | None = None) -> list[dict]:
|
||||||
|
"""Build Block Kit blocks showing the week's schedule.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule: ShiftSchedule instance
|
||||||
|
start_date: Monday of the week to show. Defaults to current week's Monday.
|
||||||
|
"""
|
||||||
|
now = datetime.now(EASTERN)
|
||||||
|
if start_date is None:
|
||||||
|
start_date = now - timedelta(days=now.weekday()) # Monday of this week
|
||||||
|
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
today_str = now.strftime("%Y-%m-%d")
|
||||||
|
week_label = start_date.strftime("%b %-d")
|
||||||
|
end_label = (start_date + timedelta(days=6)).strftime("%b %-d")
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
open_dates = []
|
||||||
|
for i, day_name in enumerate(DAY_ORDER):
|
||||||
|
date = start_date + timedelta(days=i)
|
||||||
|
date_str = date.strftime("%Y-%m-%d")
|
||||||
|
ext, name, source = schedule.resolve_shift(date_str, day_name)
|
||||||
|
is_today = date_str == today_str
|
||||||
|
lines.append(_format_shift_line(date, ext, name, source, is_today))
|
||||||
|
if source == "available":
|
||||||
|
open_dates.append(date_str)
|
||||||
|
|
||||||
|
blocks = [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"text": {"type": "plain_text", "text": f"After-Hours Schedule — {week_label} to {end_label}"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {"type": "mrkdwn", "text": "\n".join(lines)},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add pickup buttons for open shifts
|
||||||
|
if open_dates:
|
||||||
|
blocks.append({"type": "divider"})
|
||||||
|
elements = []
|
||||||
|
for date_str in open_dates:
|
||||||
|
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||||
|
elements.append(
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"text": {"type": "plain_text", "text": f"Pick up {dt.strftime('%a %b %-d')}"},
|
||||||
|
"action_id": f"pickup_{date_str}",
|
||||||
|
"style": "primary",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
blocks.append({"type": "actions", "elements": elements[:5]}) # Slack max 5 buttons
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def build_shift_change_message(
|
||||||
|
user_id: str, date_str: str, action: str, ext: str, name: str
|
||||||
|
) -> 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")
|
||||||
|
|
||||||
|
if action == "picked_up":
|
||||||
|
text = f":white_check_mark: <@{user_id}> picked up the shift for *{day_label}* (Ext {ext})"
|
||||||
|
elif action == "dropped":
|
||||||
|
text = f":warning: <@{user_id}> dropped the shift for *{day_label}* — it's now *Available*"
|
||||||
|
elif action == "swapped":
|
||||||
|
text = f":arrows_counterclockwise: <@{user_id}> swapped into the shift for *{day_label}* (Ext {ext})"
|
||||||
|
else:
|
||||||
|
text = f"Shift updated for *{day_label}*: {name} (Ext {ext})"
|
||||||
|
|
||||||
|
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
|
||||||
|
|
||||||
|
|
||||||
|
def build_help_blocks() -> list[dict]:
|
||||||
|
"""Build help message blocks."""
|
||||||
|
text = (
|
||||||
|
"*After-Hours Shift Manager*\n\n"
|
||||||
|
"`/oncall` — Show this week's schedule\n"
|
||||||
|
"`/oncall next` — Show next week's schedule\n"
|
||||||
|
"`/oncall pick <date>` — Pick up a shift\n"
|
||||||
|
"`/oncall drop <date>` — Drop your shift (marks it open)\n"
|
||||||
|
"`/oncall swap <date> @person` — Swap your shift with someone\n"
|
||||||
|
"`/oncall register <extension>` — Link your Slack account to your extension\n"
|
||||||
|
"`/oncall roster` — Show all registered employees\n"
|
||||||
|
"`/oncall help` — Show this help message\n\n"
|
||||||
|
"_Dates can be: today, tomorrow, monday, friday, 4/5, 2026-04-05_"
|
||||||
|
)
|
||||||
|
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
|
||||||
|
|
||||||
|
|
||||||
|
def build_roster_blocks(roster: list[dict]) -> list[dict]:
|
||||||
|
"""Build roster display blocks."""
|
||||||
|
if not roster:
|
||||||
|
return [{"type": "section", "text": {"type": "mrkdwn", "text": "No employees in the roster."}}]
|
||||||
|
|
||||||
|
lines = ["*On-Call Roster*\n"]
|
||||||
|
for emp in sorted(roster, key=lambda x: x["SK"]):
|
||||||
|
slack = f"<@{emp['slack_user_id']}>" if emp.get("slack_user_id") else "_not linked_"
|
||||||
|
lines.append(f"Ext {emp['SK']} — {emp.get('name', 'Unknown')} ({slack})")
|
||||||
|
|
||||||
|
return [{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}]
|
||||||
38
src/handler.py
Normal file
38
src/handler.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Lambda handler — Slack Bolt app entry point."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from slack_bolt.adapter.aws_lambda import SlackRequestHandler
|
||||||
|
|
||||||
|
from src.app import create_app
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
SlackRequestHandler.clear_all_log_handlers()
|
||||||
|
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"]
|
||||||
|
|
||||||
|
app = create_app(bot_token, signing_secret)
|
||||||
|
_slack_handler = SlackRequestHandler(app=app)
|
||||||
|
return _slack_handler
|
||||||
|
|
||||||
|
|
||||||
|
def handler(event, context):
|
||||||
|
return _get_handler().handle(event, context)
|
||||||
115
src/schedule.py
Normal file
115
src/schedule.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""DynamoDB-backed shift schedule management.
|
||||||
|
|
||||||
|
Single-table design:
|
||||||
|
PK=ROSTER SK=<extension> — employee roster entry
|
||||||
|
PK=WEEKLY SK=<DayName> — default weekly schedule
|
||||||
|
PK=OVERRIDE SK=<YYYY-MM-DD> — date-specific override (or OPEN)
|
||||||
|
PK=CONFIG SK=CONFIG — global settings
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from boto3.dynamodb.conditions import Key
|
||||||
|
|
||||||
|
|
||||||
|
FALLBACK_EXTENSION = "100"
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftSchedule:
|
||||||
|
def __init__(self, table_name: str | None = None):
|
||||||
|
dynamodb = boto3.resource("dynamodb")
|
||||||
|
self.table = dynamodb.Table(table_name or os.environ["SHIFT_TABLE"])
|
||||||
|
|
||||||
|
# ── Roster ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_roster(self) -> list[dict]:
|
||||||
|
resp = self.table.query(KeyConditionExpression=Key("PK").eq("ROSTER"))
|
||||||
|
return resp["Items"]
|
||||||
|
|
||||||
|
def get_employee_by_extension(self, extension: str) -> dict | None:
|
||||||
|
resp = self.table.get_item(Key={"PK": "ROSTER", "SK": extension})
|
||||||
|
return resp.get("Item")
|
||||||
|
|
||||||
|
def get_employee_by_slack_id(self, slack_user_id: str) -> dict | None:
|
||||||
|
for item in self.get_roster():
|
||||||
|
if item.get("slack_user_id") == slack_user_id:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
def register_user(self, slack_user_id: str, extension: str) -> dict | None:
|
||||||
|
employee = self.get_employee_by_extension(extension)
|
||||||
|
if not employee:
|
||||||
|
return None
|
||||||
|
self.table.update_item(
|
||||||
|
Key={"PK": "ROSTER", "SK": extension},
|
||||||
|
UpdateExpression="SET slack_user_id = :sid",
|
||||||
|
ExpressionAttributeValues={":sid": slack_user_id},
|
||||||
|
)
|
||||||
|
employee["slack_user_id"] = slack_user_id
|
||||||
|
return employee
|
||||||
|
|
||||||
|
# ── Weekly schedule ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_weekly_schedule(self) -> dict[str, dict]:
|
||||||
|
resp = self.table.query(KeyConditionExpression=Key("PK").eq("WEEKLY"))
|
||||||
|
return {item["SK"]: item for item in resp["Items"]}
|
||||||
|
|
||||||
|
# ── Overrides ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_override(self, date_str: str) -> dict | None:
|
||||||
|
resp = self.table.get_item(Key={"PK": "OVERRIDE", "SK": date_str})
|
||||||
|
return resp.get("Item")
|
||||||
|
|
||||||
|
def set_override(self, date_str: str, extension: str, name: str) -> None:
|
||||||
|
self.table.put_item(
|
||||||
|
Item={
|
||||||
|
"PK": "OVERRIDE",
|
||||||
|
"SK": date_str,
|
||||||
|
"extension": extension,
|
||||||
|
"name": name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_open(self, date_str: str) -> None:
|
||||||
|
self.table.put_item(
|
||||||
|
Item={
|
||||||
|
"PK": "OVERRIDE",
|
||||||
|
"SK": date_str,
|
||||||
|
"extension": "OPEN",
|
||||||
|
"name": "Open",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def remove_override(self, date_str: str) -> None:
|
||||||
|
self.table.delete_item(Key={"PK": "OVERRIDE", "SK": date_str})
|
||||||
|
|
||||||
|
# ── Resolution ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def resolve_shift(self, date_str: str, day_name: str) -> tuple[str, str, str]:
|
||||||
|
"""Return (extension, name, source) for a given date.
|
||||||
|
|
||||||
|
source is one of: 'override', 'available', 'weekly'.
|
||||||
|
'available' means no one is assigned — the fallback extension handles
|
||||||
|
it silently on the 3CX side, but users just see it as open for pickup.
|
||||||
|
"""
|
||||||
|
override = self.get_override(date_str)
|
||||||
|
if override:
|
||||||
|
if override["extension"] == "OPEN":
|
||||||
|
return (FALLBACK_EXTENSION, "Available", "available")
|
||||||
|
return (override["extension"], override["name"], "override")
|
||||||
|
|
||||||
|
weekly = self.get_weekly_schedule()
|
||||||
|
if day_name in weekly:
|
||||||
|
item = weekly[day_name]
|
||||||
|
if item["extension"] == FALLBACK_EXTENSION:
|
||||||
|
return (FALLBACK_EXTENSION, "Available", "available")
|
||||||
|
return (item["extension"], item.get("name", "Unknown"), "weekly")
|
||||||
|
|
||||||
|
return (FALLBACK_EXTENSION, "Available", "available")
|
||||||
|
|
||||||
|
# ── Config ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_config(self) -> dict:
|
||||||
|
resp = self.table.get_item(Key={"PK": "CONFIG", "SK": "CONFIG"})
|
||||||
|
return resp.get("Item", {})
|
||||||
49
src/weekly_post.py
Normal file
49
src/weekly_post.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Lambda handler — posts the weekly on-call schedule to Slack every Monday at 7am ET."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from slack_sdk import WebClient
|
||||||
|
|
||||||
|
from src.blocks import build_week_schedule
|
||||||
|
from src.schedule import ShiftSchedule
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
|
||||||
|
|
||||||
|
def handler(event, context):
|
||||||
|
now = datetime.now(EASTERN)
|
||||||
|
|
||||||
|
# DST guard — same pattern as the 3CX scheduler
|
||||||
|
if now.hour != 7:
|
||||||
|
logger.info("ET hour is %d, not 7am — skipping (wrong DST rule fired)", now.hour)
|
||||||
|
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"]
|
||||||
|
|
||||||
|
schedule = ShiftSchedule()
|
||||||
|
slack = WebClient(token=bot_token)
|
||||||
|
|
||||||
|
# Show this week (Monday = today)
|
||||||
|
blocks = build_week_schedule(schedule, start_date=now)
|
||||||
|
|
||||||
|
slack.chat_postMessage(
|
||||||
|
channel=channel_id,
|
||||||
|
blocks=blocks,
|
||||||
|
text=f"After-Hours Schedule — Week of {now.strftime('%b %-d')}",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Posted weekly schedule to channel %s", channel_id)
|
||||||
|
return {"posted": True, "channel": channel_id}
|
||||||
132
template.yaml
Normal file
132
template.yaml
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
AWSTemplateFormatVersion: "2010-09-09"
|
||||||
|
Transform: AWS::Serverless-2016-10-31
|
||||||
|
Description: After-Hours Shift Manager — Slack bot for managing on-call shifts with 3CX integration
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
Timezone:
|
||||||
|
Type: String
|
||||||
|
Default: "America/New_York"
|
||||||
|
SchedulerFunctionName:
|
||||||
|
Type: String
|
||||||
|
Default: "3cx-ring-group-scheduler"
|
||||||
|
Description: Name of the existing 3CX ring group scheduler Lambda
|
||||||
|
|
||||||
|
Globals:
|
||||||
|
Function:
|
||||||
|
Runtime: python3.12
|
||||||
|
Timeout: 30
|
||||||
|
MemorySize: 1024
|
||||||
|
|
||||||
|
Resources:
|
||||||
|
# --- DynamoDB ---
|
||||||
|
ShiftTable:
|
||||||
|
Type: AWS::DynamoDB::Table
|
||||||
|
Properties:
|
||||||
|
TableName: afterhours-shifts
|
||||||
|
BillingMode: PAY_PER_REQUEST
|
||||||
|
AttributeDefinitions:
|
||||||
|
- AttributeName: PK
|
||||||
|
AttributeType: S
|
||||||
|
- AttributeName: SK
|
||||||
|
AttributeType: S
|
||||||
|
KeySchema:
|
||||||
|
- AttributeName: PK
|
||||||
|
KeyType: HASH
|
||||||
|
- AttributeName: SK
|
||||||
|
KeyType: RANGE
|
||||||
|
|
||||||
|
# --- Slack Bot Lambda ---
|
||||||
|
SlackBotFunction:
|
||||||
|
Type: AWS::Serverless::Function
|
||||||
|
Properties:
|
||||||
|
FunctionName: afterhours-shift-manager
|
||||||
|
Handler: src/handler.handler
|
||||||
|
CodeUri: .
|
||||||
|
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
|
||||||
|
SCHEDULER_FUNCTION_NAME: !Ref SchedulerFunctionName
|
||||||
|
TZ: !Ref Timezone
|
||||||
|
Policies:
|
||||||
|
- DynamoDBCrudPolicy:
|
||||||
|
TableName: !Ref ShiftTable
|
||||||
|
- Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- ssm:GetParameter
|
||||||
|
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}"
|
||||||
|
Events:
|
||||||
|
SlackEvents:
|
||||||
|
Type: HttpApi
|
||||||
|
Properties:
|
||||||
|
Path: /slack/events
|
||||||
|
Method: POST
|
||||||
|
|
||||||
|
# --- Weekly Schedule Post (Monday 7am ET) ---
|
||||||
|
WeeklyPostFunction:
|
||||||
|
Type: AWS::Serverless::Function
|
||||||
|
Properties:
|
||||||
|
FunctionName: afterhours-weekly-post
|
||||||
|
Handler: src/weekly_post.handler
|
||||||
|
CodeUri: .
|
||||||
|
Environment:
|
||||||
|
Variables:
|
||||||
|
SHIFT_TABLE: !Ref ShiftTable
|
||||||
|
SLACK_BOT_TOKEN_PARAM: /afterhours-shift-manager/slack-bot-token
|
||||||
|
SHIFT_CHANNEL_PARAM: /afterhours-shift-manager/channel-id
|
||||||
|
TZ: !Ref Timezone
|
||||||
|
Policies:
|
||||||
|
- DynamoDBReadPolicy:
|
||||||
|
TableName: !Ref ShiftTable
|
||||||
|
- Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- ssm:GetParameter
|
||||||
|
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"
|
||||||
|
Events:
|
||||||
|
# EST: 7am ET = 12:00 UTC (Nov-Mar)
|
||||||
|
WeeklyPostEST:
|
||||||
|
Type: Schedule
|
||||||
|
Properties:
|
||||||
|
Schedule: cron(0 12 ? * MON *)
|
||||||
|
Description: "Post weekly schedule Monday 7am EST"
|
||||||
|
Enabled: true
|
||||||
|
# EDT: 7am ET = 11:00 UTC (Mar-Nov)
|
||||||
|
WeeklyPostEDT:
|
||||||
|
Type: Schedule
|
||||||
|
Properties:
|
||||||
|
Schedule: cron(0 11 ? * MON *)
|
||||||
|
Description: "Post weekly schedule Monday 7am EDT"
|
||||||
|
Enabled: true
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
SlackBotApiUrl:
|
||||||
|
Description: URL for Slack app Request URL configuration
|
||||||
|
Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/slack/events"
|
||||||
|
ShiftTableName:
|
||||||
|
Value: !Ref ShiftTable
|
||||||
|
SlackBotFunctionArn:
|
||||||
|
Value: !GetAtt SlackBotFunction.Arn
|
||||||
Loading…
Add table
Reference in a new issue