mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 07:53:11 +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