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:
Adam Moussa 2026-04-03 18:32:32 -04:00
commit 4d0cdb5cfb
12 changed files with 1020 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
__pycache__/
*.pyc
.aws-sam/
venv/
.env
samconfig.toml
output.json

72
README.md Normal file
View 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
View 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
View file

@ -0,0 +1,2 @@
slack_bolt>=1.18.0,<2.0
boto3>=1.28.0

89
scripts/seed_schedule.py Normal file
View 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
View file

295
src/app.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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