From 4d0cdb5cfb51be4d46df8b1363d0f8bb9bf36123 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:32:32 -0400 Subject: [PATCH] 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 --- .gitignore | 7 + README.md | 72 ++++++++++ SETUP.md | 95 +++++++++++++ requirements.txt | 2 + scripts/seed_schedule.py | 89 ++++++++++++ src/__init__.py | 0 src/app.py | 295 +++++++++++++++++++++++++++++++++++++++ src/blocks.py | 126 +++++++++++++++++ src/handler.py | 38 +++++ src/schedule.py | 115 +++++++++++++++ src/weekly_post.py | 49 +++++++ template.yaml | 132 ++++++++++++++++++ 12 files changed, 1020 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SETUP.md create mode 100644 requirements.txt create mode 100644 scripts/seed_schedule.py create mode 100644 src/__init__.py create mode 100644 src/app.py create mode 100644 src/blocks.py create mode 100644 src/handler.py create mode 100644 src/schedule.py create mode 100644 src/weekly_post.py create mode 100644 template.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80fccd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.aws-sam/ +venv/ +.env +samconfig.toml +output.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a54c4e --- /dev/null +++ b/README.md @@ -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 ` | Pick up an available shift | +| `/oncall drop ` | Drop your shift (marks it available) | +| `/oncall swap @person` | Hand your shift to someone else | +| `/oncall register ` | 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` | `` | Employee: name, extension, slack_user_id | +| `WEEKLY` | `` | Default weekly schedule: extension, name | +| `OVERRIDE` | `` | 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 diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..bb85134 --- /dev/null +++ b/SETUP.md @@ -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 | drop | swap @person | register | 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 ` | Pick up a shift | +| `/oncall drop ` | Drop your shift (marks it open) | +| `/oncall swap @person` | Hand your shift to someone else | +| `/oncall register ` | 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` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1721228 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +slack_bolt>=1.18.0,<2.0 +boto3>=1.28.0 diff --git a/scripts/seed_schedule.py b/scripts/seed_schedule.py new file mode 100644 index 0000000..604430a --- /dev/null +++ b/scripts/seed_schedule.py @@ -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 ` 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) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..2c58bbd --- /dev/null +++ b/src/app.py @@ -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 ` 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 ` (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 ` (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 ` 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 ` (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 ` 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 @user OR swap + parts = text.split(maxsplit=2) + if len(parts) < 3: + respond(text="Usage: `/oncall swap @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 ` 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 `.") + 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 diff --git a/src/blocks.py b/src/blocks.py new file mode 100644 index 0000000..7a4b68c --- /dev/null +++ b/src/blocks.py @@ -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 ` — Pick up a shift\n" + "`/oncall drop ` — Drop your shift (marks it open)\n" + "`/oncall swap @person` — Swap your shift with someone\n" + "`/oncall register ` — 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)}}] diff --git a/src/handler.py b/src/handler.py new file mode 100644 index 0000000..4cd7dbd --- /dev/null +++ b/src/handler.py @@ -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) diff --git a/src/schedule.py b/src/schedule.py new file mode 100644 index 0000000..aaf38e7 --- /dev/null +++ b/src/schedule.py @@ -0,0 +1,115 @@ +"""DynamoDB-backed shift schedule management. + +Single-table design: + PK=ROSTER SK= — employee roster entry + PK=WEEKLY SK= — default weekly schedule + PK=OVERRIDE SK= — 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", {}) diff --git a/src/weekly_post.py b/src/weekly_post.py new file mode 100644 index 0000000..2ef6d21 --- /dev/null +++ b/src/weekly_post.py @@ -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} diff --git a/template.yaml b/template.yaml new file mode 100644 index 0000000..35ea4b4 --- /dev/null +++ b/template.yaml @@ -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