mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 12:33:13 +00:00
* Fix auth and race-condition flaws in shift commands Four confirmed findings from the 2026-06-17 security sweep: - register_user let any Slack user overwrite an extension already bound to a different user (account takeover). Add a DynamoDB ConditionExpression so a write only succeeds when the extension is unclaimed or already this user's; raise ExtensionAlreadyRegistered otherwise and surface a clear Slack message. - The `rate` subcommand was routed without the is_admin flag, so any user could set $0 pay rates. Gate _handle_rate on is_admin, matching the admin-command guard. - `/oncall pick` used a plain put_item (TOCTOU): two concurrent picks both won. Use the atomic claim_open_shift conditional claim so the loser gets an "already picked up" message. - swap-accept overwrote a shift independently claimed after the swap was initiated. Add reassign_if_held_by, a conditional write that only applies the swap while the override is still the requester's (or on the weekly fallback), and notify the accepter otherwise. Add tests for the register-ownership guard and the rate admin guard. Refs: INFRA * Scope shift-manager Lambda IAM to least privilege The nightly sweep flagged four over-broad permissions. Scope each to only what the function actually reads (verified against source): - WeeklyPost: secrets to slack-bot-token-* only (was the whole afterhours-shift-manager/* namespace); SES SendEmail to the single noreply@seahaven.com identity (was identity/*). - RosterSync and RingScheduler: secrets to 3cx-* only (was the whole namespace); both read only the 3cx domain/client-id/client-secret. SlackBotFunction and HolidayRouter wildcards are left unchanged — out of scope for this sweep. Refs: INFRA * fix: re-validate shift holder on swap-accept (sh-security-review RIHB-1) reassign_if_held_by trusted 'no override row' as 'still the requester's', but a weekly-held shift also has no override row. An admin clear or weekly edit between swap-init and accept could move the shift to a third party with no override, letting the accept steal it (CWE-367, confirmed HIGH). Re-resolve the current holder at accept and abort if it is no longer the requester. Adds regression test + seeds the holder in existing accept tests. * fix: complete IAM least-privilege sweep (sh-security-review) HolidayRouter secrets scope afterhours-shift-manager/* -> /3cx-* (reads only 3cx secrets); RingScheduler DynamoDBCrudPolicy -> DynamoDBReadPolicy (read-only at runtime). SlackBot wildcard left as-is (reads across all sub-prefixes; verified defensible).
1990 lines
70 KiB
Python
1990 lines
70 KiB
Python
"""Slack Bolt app — /oncall command handlers and interactive actions.
|
||
|
||
The handler functions are module-level (not closures over ``create_app``) so they
|
||
can be unit-tested directly. ``schedule`` (a ``ShiftSchedule``) and
|
||
``schedule_channel`` are threaded through as explicit parameters. ``create_app``
|
||
is a thin wiring layer that registers the Bolt routes and delegates to them.
|
||
"""
|
||
|
||
import functools
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
from datetime import datetime, timedelta
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import boto3
|
||
from boto3.dynamodb.conditions import Key
|
||
from slack_bolt import App
|
||
|
||
from shared.blocks import (
|
||
build_help_blocks,
|
||
build_holiday_added_blocks,
|
||
build_home_view,
|
||
build_pay_summary_blocks,
|
||
build_pickup_request_blocks,
|
||
build_pickup_resolved_blocks,
|
||
build_roster_blocks,
|
||
build_shift_change_message,
|
||
build_swap_request_blocks,
|
||
build_swap_resolved_blocks,
|
||
build_week_schedule,
|
||
)
|
||
from shared.changelog import latest_entry
|
||
from shared.ring_scheduler import update_queue_routing
|
||
from shared.schedule import (
|
||
FALLBACK_EXTENSION,
|
||
WEEKEND_DAYS,
|
||
ExtensionAlreadyRegistered,
|
||
ShiftSchedule,
|
||
determine_shift_type,
|
||
)
|
||
from shared.secrets import get_secret
|
||
from shared.three_cx_client import ThreeCXClient
|
||
|
||
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 _update_3cx_routing(extension: str) -> None:
|
||
"""Update the 3CX queue to forward calls to the given extension."""
|
||
queue_number = os.environ.get("QUEUE_NUMBER")
|
||
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
|
||
if not queue_number or not secret_prefix:
|
||
logger.warning("3CX env vars not set — skipping queue update")
|
||
return
|
||
try:
|
||
update_queue_routing(
|
||
extension=extension,
|
||
queue_number=queue_number,
|
||
domain=get_secret(f"{secret_prefix}domain"),
|
||
client_id=get_secret(f"{secret_prefix}client-id"),
|
||
client_secret=get_secret(f"{secret_prefix}client-secret"),
|
||
)
|
||
except Exception:
|
||
logger.exception("Failed to update 3CX queue")
|
||
|
||
|
||
def _make_3cx_client() -> ThreeCXClient | None:
|
||
"""Build an OAuth ThreeCXClient from the configured secret prefix, or None.
|
||
|
||
Mirrors ``_update_3cx_routing``'s tolerance of a missing config: returns
|
||
None (and logs) rather than raising when ``TCX_SECRET_PREFIX`` is unset, so
|
||
holiday flows degrade gracefully in environments without 3CX wired up.
|
||
"""
|
||
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
|
||
if not secret_prefix:
|
||
logger.warning("3CX env vars not set — skipping 3CX call")
|
||
return None
|
||
return ThreeCXClient(
|
||
domain=get_secret(f"{secret_prefix}domain"),
|
||
auth_mode="oauth",
|
||
client_id=get_secret(f"{secret_prefix}client-id"),
|
||
client_secret=get_secret(f"{secret_prefix}client-secret"),
|
||
)
|
||
|
||
|
||
def _set_holiday_queue_agents(schedule, date_str: str) -> None:
|
||
"""Point the holiday queue (802) at the current holiday assignees.
|
||
|
||
Used by the inline-activation path and the late-pickup approval path when
|
||
the holiday window is open — the membership must reflect the live assignee
|
||
set. Falls back to ``[FALLBACK_EXTENSION]`` ("100") when no slot is filled.
|
||
Best-effort: any failure is logged and swallowed so the Slack flow still
|
||
completes.
|
||
"""
|
||
holiday = schedule.get_holiday(date_str)
|
||
if holiday is None:
|
||
return
|
||
assignees = holiday.get("assignees", {}) or {}
|
||
extensions = list(assignees.keys()) or [FALLBACK_EXTENSION]
|
||
try:
|
||
client = _make_3cx_client()
|
||
if client is None:
|
||
return
|
||
queue_number = schedule.get_holiday_queue()
|
||
queue = client.get_queue(queue_number)
|
||
client.set_queue_agents(queue["Id"], extensions)
|
||
except Exception:
|
||
logger.exception("Failed to set holiday queue agents for %s", date_str)
|
||
|
||
|
||
def _activate_holiday_inline(schedule, date_str: str) -> None:
|
||
"""Invoke the holiday router's activate path now, for a holiday added late.
|
||
|
||
When an admin schedules a holiday whose window is already open (08:00 ≤ now <
|
||
17:00 ET), the 08:00 activation schedule has already passed, so the call flow
|
||
must be repointed immediately. We invoke the holiday-router Lambda
|
||
asynchronously so the (idempotent) activate logic — IVR capture/repoint,
|
||
queue membership, ``activated`` flag — runs exactly as it would at 08:00.
|
||
Best-effort: a missing ARN or invoke failure is logged, not raised.
|
||
"""
|
||
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
|
||
if not router_arn:
|
||
logger.warning("HOLIDAY_ROUTER_ARN not set — skipping inline activation")
|
||
return
|
||
try:
|
||
boto3.client("lambda").invoke(
|
||
FunctionName=router_arn,
|
||
InvocationType="Event",
|
||
Payload=json.dumps({"action": "activate", "date": date_str}).encode(),
|
||
)
|
||
logger.info("Invoked holiday router inline activate for %s", date_str)
|
||
except Exception:
|
||
logger.exception("Failed to invoke holiday router for %s", date_str)
|
||
|
||
|
||
def _holiday_schedule_names(date_str: str) -> tuple[str, str]:
|
||
"""The (activate, deactivate) one-off schedule names for a holiday date."""
|
||
compact = date_str.replace("-", "")
|
||
return f"holiday-activate-{compact}", f"holiday-deactivate-{compact}"
|
||
|
||
|
||
def _create_holiday_schedules(date_str: str) -> list[str]:
|
||
"""Create the two one-off EventBridge schedules for a holiday and return names.
|
||
|
||
One schedule fires the holiday router's ``activate`` at 08:00 ET on the
|
||
date, the other its ``deactivate`` at 17:00 ET. Both use a flexible
|
||
one-time ``at(...)`` expression in ``America/New_York``,
|
||
``ActionAfterCompletion=DELETE`` (self-cleanup once fired), and target the
|
||
holiday-router Lambda via the passed scheduler execution role.
|
||
|
||
Returns the created schedule names (stored on the HOLIDAY record so a later
|
||
``remove`` can delete any that have not yet fired). Best-effort: returns the
|
||
names it managed to create; missing config short-circuits to ``[]``.
|
||
"""
|
||
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
|
||
role_arn = os.environ.get("HOLIDAY_SCHEDULER_ROLE_ARN")
|
||
group = os.environ.get("HOLIDAY_SCHEDULE_GROUP", "default")
|
||
if not router_arn or not role_arn:
|
||
logger.warning(
|
||
"HOLIDAY_ROUTER_ARN/HOLIDAY_SCHEDULER_ROLE_ARN not set — "
|
||
"skipping schedule creation"
|
||
)
|
||
return []
|
||
|
||
activate_name, deactivate_name = _holiday_schedule_names(date_str)
|
||
client = boto3.client("scheduler")
|
||
created: list[str] = []
|
||
specs = [
|
||
(activate_name, "activate", "08:00:00"),
|
||
(deactivate_name, "deactivate", "17:00:00"),
|
||
]
|
||
for name, action, at_time in specs:
|
||
try:
|
||
client.create_schedule(
|
||
Name=name,
|
||
GroupName=group,
|
||
ScheduleExpression=f"at({date_str}T{at_time})",
|
||
ScheduleExpressionTimezone="America/New_York",
|
||
FlexibleTimeWindow={"Mode": "OFF"},
|
||
ActionAfterCompletion="DELETE",
|
||
Target={
|
||
"Arn": router_arn,
|
||
"RoleArn": role_arn,
|
||
"Input": json.dumps({"action": action, "date": date_str}),
|
||
},
|
||
)
|
||
created.append(name)
|
||
except Exception:
|
||
logger.exception("Failed to create %s schedule for %s", action, date_str)
|
||
return created
|
||
|
||
|
||
def _delete_holiday_schedules(schedule_names: list[str]) -> None:
|
||
"""Delete any still-outstanding one-off holiday schedules (best-effort).
|
||
|
||
A schedule that has already fired self-deletes (ActionAfterCompletion=DELETE),
|
||
so a ResourceNotFound on delete is expected and ignored.
|
||
"""
|
||
if not schedule_names:
|
||
return
|
||
group = os.environ.get("HOLIDAY_SCHEDULE_GROUP", "default")
|
||
try:
|
||
client = boto3.client("scheduler")
|
||
except Exception:
|
||
logger.exception("Could not create scheduler client to delete schedules")
|
||
return
|
||
for name in schedule_names:
|
||
try:
|
||
client.delete_schedule(Name=name, GroupName=group)
|
||
except client.exceptions.ResourceNotFoundException:
|
||
logger.info("Holiday schedule %s already gone — nothing to delete", name)
|
||
except Exception:
|
||
logger.exception("Failed to delete holiday schedule %s", name)
|
||
|
||
|
||
def is_today(date_str: str) -> bool:
|
||
return date_str == datetime.now(EASTERN).strftime("%Y-%m-%d")
|
||
|
||
|
||
def _is_active_shift_type(shift_type: str) -> bool:
|
||
return determine_shift_type() == shift_type
|
||
|
||
|
||
def _shift_start(date_str: str, shift_type: str) -> datetime:
|
||
"""The datetime (ET) a shift begins — weekend day at 08:00, otherwise 17:00."""
|
||
d = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=EASTERN)
|
||
return d.replace(hour=8 if shift_type == "day" else 17)
|
||
|
||
|
||
def _shift_started(date_str: str, shift_type: str) -> bool:
|
||
return datetime.now(EASTERN) >= _shift_start(date_str, shift_type)
|
||
|
||
|
||
def _shift_end(date_str: str, shift_type: str) -> datetime:
|
||
"""The datetime (ET) a shift ends.
|
||
|
||
A day shift (weekend day or holiday, 08:00–17:00 ET) ends at 17:00 the same
|
||
day. A night shift (17:00–08:00 ET) ends at 08:00 the *next* day.
|
||
"""
|
||
d = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=EASTERN)
|
||
if shift_type == "day":
|
||
return d.replace(hour=17)
|
||
return d.replace(hour=8) + timedelta(days=1)
|
||
|
||
|
||
def _shift_ended(date_str: str, shift_type: str) -> bool:
|
||
return datetime.now(EASTERN) >= _shift_end(date_str, shift_type)
|
||
|
||
|
||
def _holiday_window_active(date_str: str) -> bool:
|
||
"""True when a holiday day-shift window (08:00–17:00 ET) is currently open.
|
||
|
||
The 3CX call flow is only repointed to the holiday queue during this window,
|
||
so inline activation (admin add) and late-pickup membership refreshes are
|
||
gated on it.
|
||
"""
|
||
now = datetime.now(EASTERN)
|
||
return _shift_start(date_str, "day") <= now < _shift_end(date_str, "day")
|
||
|
||
|
||
def _within_drop_lock(date_str: str, shift_type: str) -> bool:
|
||
"""True inside the 24h-before-start window where dropping a shift is locked.
|
||
|
||
Within this window a shift can't be abandoned via drop — it must be handed
|
||
off through a verified swap (target accepts) or opened by an admin.
|
||
"""
|
||
return datetime.now(EASTERN) >= _shift_start(date_str, shift_type) - timedelta(
|
||
hours=24
|
||
)
|
||
|
||
|
||
def _shift_type_label(day_name: str, shift_type: str) -> str:
|
||
if day_name not in WEEKEND_DAYS:
|
||
return ""
|
||
label = "Day" if shift_type == "day" else "Night"
|
||
return f" ({label})"
|
||
|
||
|
||
def _find_employee_shift(schedule, date_str, day_name, employee_ext):
|
||
"""Find which shift type an employee is assigned to on a given date.
|
||
|
||
On weekends, checks both day and night shifts. Returns (ext, name, source, shift_type)
|
||
or None if not found on any shift.
|
||
"""
|
||
if day_name in WEEKEND_DAYS:
|
||
for st in ("day", "night"):
|
||
ext, name, source = schedule.resolve_shift(date_str, day_name, st)
|
||
if ext == employee_ext:
|
||
return ext, name, source, st
|
||
return None
|
||
ext, name, source = schedule.resolve_shift(date_str, day_name, "night")
|
||
if ext == employee_ext:
|
||
return ext, name, source, "night"
|
||
return None
|
||
|
||
|
||
def _refresh_schedule_post(schedule, schedule_channel, client):
|
||
"""Update the pinned schedule message in-place after a shift change."""
|
||
channel = schedule_channel
|
||
if not channel:
|
||
return
|
||
post = schedule.get_schedule_post(channel)
|
||
if not post or not post.get("message_ts"):
|
||
return
|
||
try:
|
||
blocks = build_week_schedule(schedule)
|
||
now = datetime.now(EASTERN)
|
||
this_monday = now - timedelta(days=now.weekday())
|
||
end_date = this_monday + timedelta(days=13)
|
||
client.chat_update(
|
||
channel=channel,
|
||
ts=post["message_ts"],
|
||
blocks=blocks,
|
||
text=f"After-Hours Schedule — {this_monday.strftime('%b %-d')} to {end_date.strftime('%b %-d')}",
|
||
)
|
||
except Exception:
|
||
logger.warning("Could not update schedule post", exc_info=True)
|
||
|
||
|
||
# ── Command dispatch ────────────────────────────────────────────────────
|
||
|
||
|
||
def dispatch_oncall(command, respond, client, schedule, schedule_channel):
|
||
"""Route a /oncall slash command to the appropriate subcommand handler."""
|
||
text = (command.get("text") or "").strip()
|
||
user_id = command["user_id"]
|
||
channel_id = command["channel_id"]
|
||
|
||
is_admin = user_id in schedule.get_admin_users()
|
||
post_channel = schedule_channel or 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(is_admin=is_admin))
|
||
elif text == "roster":
|
||
respond(blocks=build_roster_blocks(schedule.get_roster()))
|
||
elif text == "pay":
|
||
_show_pay(respond, schedule)
|
||
elif text.startswith("rate"):
|
||
_handle_rate(respond, schedule, text, is_admin)
|
||
elif text.startswith("register"):
|
||
_handle_register(respond, schedule, user_id, text)
|
||
elif text.startswith("pick"):
|
||
_handle_pick(
|
||
respond, schedule, user_id, text, post_channel, client, schedule_channel
|
||
)
|
||
elif text.startswith("drop"):
|
||
_handle_drop(
|
||
respond, schedule, user_id, text, post_channel, client, schedule_channel
|
||
)
|
||
elif text.startswith("swap"):
|
||
_handle_swap(
|
||
respond, schedule, user_id, text, post_channel, client, schedule_channel
|
||
)
|
||
elif text.startswith("admin"):
|
||
_handle_admin(
|
||
respond, schedule, user_id, text, is_admin, client, schedule_channel
|
||
)
|
||
else:
|
||
respond(text="Unknown command. Try `/oncall help`")
|
||
|
||
|
||
def handle_pickup(body, respond, client, schedule, schedule_channel):
|
||
"""Handle the interactive "pick up open shift" button.
|
||
|
||
Routes holiday day-shift buttons to an atomic slot claim and gates pickups
|
||
of an already-started shift behind admin approval (same flow as ``pick``).
|
||
"""
|
||
action_id = body["actions"][0]["action_id"]
|
||
remainder = action_id[len("pickup_") :]
|
||
if remainder.endswith("_day"):
|
||
date_str = remainder[:-4]
|
||
shift_type = "day"
|
||
else:
|
||
date_str = remainder
|
||
shift_type = "night"
|
||
|
||
user_id = body["user"]["id"]
|
||
channel_id = body["channel"]["id"]
|
||
|
||
today_str = datetime.now(EASTERN).strftime("%Y-%m-%d")
|
||
if date_str < today_str:
|
||
client.chat_postEphemeral(
|
||
channel=channel_id,
|
||
user=user_id,
|
||
text="That shift has already passed and can't be picked up.",
|
||
)
|
||
return
|
||
|
||
employee = schedule.get_employee_by_slack_id(user_id)
|
||
if not employee:
|
||
client.chat_postEphemeral(
|
||
channel=channel_id,
|
||
user=user_id,
|
||
text="You're not registered. Use `/oncall register <extension>` first.",
|
||
)
|
||
return
|
||
|
||
day_name = datetime.strptime(date_str, "%Y-%m-%d").strftime("%A")
|
||
ctx = schedule.get_shift_context(date_str, day_name, shift_type)
|
||
is_holiday = ctx["kind"] == "holiday"
|
||
|
||
def _ephemeral(text):
|
||
client.chat_postEphemeral(channel=channel_id, user=user_id, text=text)
|
||
|
||
# A pickup after the shift has ended is rejected outright; after it has
|
||
# started (but before it ends) it needs admin approval.
|
||
if _shift_ended(date_str, shift_type):
|
||
_ephemeral("That shift has already ended and can't be picked up.")
|
||
return
|
||
if _shift_started(date_str, shift_type):
|
||
_request_late_pickup(
|
||
_ephemeral_respond(_ephemeral),
|
||
schedule,
|
||
employee,
|
||
date_str,
|
||
shift_type,
|
||
is_holiday=is_holiday,
|
||
client=client,
|
||
)
|
||
return
|
||
|
||
if is_holiday:
|
||
claimed = schedule.claim_holiday_slot(
|
||
date_str, employee["extension"], employee["name"]
|
||
)
|
||
else:
|
||
claimed = schedule.claim_open_shift(
|
||
date_str, employee["extension"], employee["name"], shift_type
|
||
)
|
||
if not claimed:
|
||
_ephemeral(f"That shift on *{date_str}* was already picked up by someone else.")
|
||
return
|
||
|
||
if not is_holiday and is_today(date_str) and _is_active_shift_type(shift_type):
|
||
_update_3cx_routing(employee["extension"])
|
||
|
||
blocks = build_shift_change_message(
|
||
user_id,
|
||
date_str,
|
||
"picked_up",
|
||
employee["extension"],
|
||
employee["name"],
|
||
shift_type=shift_type,
|
||
)
|
||
respond(
|
||
response_type="in_channel",
|
||
replace_original=False,
|
||
blocks=blocks,
|
||
text=f"Shift picked up for {date_str}",
|
||
)
|
||
_refresh_schedule_post(schedule, schedule_channel, client)
|
||
|
||
|
||
def _ephemeral_respond(post_ephemeral):
|
||
"""Adapt an ephemeral-poster into a ``respond(text=...)`` callable.
|
||
|
||
``_request_late_pickup`` reports its outcome via ``respond(text=...)``; the
|
||
button path has no ``respond`` that targets the clicker, so this wraps the
|
||
ephemeral poster so the same helper serves both the slash command and the
|
||
button.
|
||
"""
|
||
|
||
def _respond(text="", **_kwargs):
|
||
if text:
|
||
post_ephemeral(text)
|
||
|
||
return _respond
|
||
|
||
|
||
# ── 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)
|
||
# Jump 2 weeks ahead from this week's Monday
|
||
this_monday = now - timedelta(days=now.weekday())
|
||
next_start = this_monday + timedelta(days=14)
|
||
blocks = build_week_schedule(schedule, start_date=next_start)
|
||
respond(blocks=blocks)
|
||
|
||
|
||
def _show_pay(respond, schedule):
|
||
now = datetime.now(EASTERN)
|
||
# Show last completed week's pay (previous Monday–Sunday)
|
||
this_monday = now - timedelta(days=now.weekday())
|
||
prev_monday = this_monday - timedelta(days=7)
|
||
week_key = prev_monday.strftime("%Y-%m-%d")
|
||
|
||
pay_record = schedule.get_pay_record(week_key)
|
||
if pay_record and pay_record.get("breakdown"):
|
||
prev_sunday = prev_monday + timedelta(days=6)
|
||
week_label = (
|
||
f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
|
||
)
|
||
blocks = build_pay_summary_blocks(
|
||
week_label, pay_record["breakdown"], pay_record["totals"]
|
||
)
|
||
respond(blocks=blocks)
|
||
else:
|
||
respond(
|
||
text=f"No pay record found for the week of {prev_monday.strftime('%b %-d')}."
|
||
)
|
||
|
||
|
||
def _handle_rate(respond, schedule, text, is_admin):
|
||
# Setting pay rates is an admin-only operation; reading them is gated too
|
||
# since rates are sensitive payroll data.
|
||
if not is_admin:
|
||
respond(text="Rate commands are restricted. Contact an administrator.")
|
||
return
|
||
|
||
parts = text.split()
|
||
# /oncall rate — show current rates
|
||
if len(parts) == 1:
|
||
default_rate = schedule.get_shift_rate()
|
||
roster = schedule.get_roster()
|
||
lines = [f"*Default rate:* ${default_rate:.2f}/shift\n"]
|
||
custom = [
|
||
(e["SK"], e.get("name", "Unknown"), float(e["shift_rate"]))
|
||
for e in roster
|
||
if e.get("shift_rate")
|
||
]
|
||
if custom:
|
||
lines.append("*Per-person rates:*")
|
||
for ext, name, rate in sorted(custom, key=lambda x: x[0]):
|
||
lines.append(f"• {name} (Ext {ext}) — ${rate:.2f}/shift")
|
||
else:
|
||
lines.append("_No per-person rates set — everyone uses the default._")
|
||
respond(text="\n".join(lines))
|
||
return
|
||
|
||
# /oncall rate default <amount>
|
||
if parts[1] == "default":
|
||
if len(parts) < 3:
|
||
respond(
|
||
text="Usage: `/oncall rate default <amount>` (e.g. `/oncall rate default 50`)"
|
||
)
|
||
return
|
||
try:
|
||
amount = float(parts[2].replace("$", ""))
|
||
except ValueError:
|
||
respond(text=f"Invalid amount: `{parts[2]}`")
|
||
return
|
||
schedule.set_default_shift_rate(amount)
|
||
respond(text=f"Default shift rate set to *${amount:.2f}*.")
|
||
return
|
||
|
||
# /oncall rate <extension> <amount>
|
||
if len(parts) < 3:
|
||
respond(
|
||
text="Usage: `/oncall rate <extension> <amount>` (e.g. `/oncall rate 114 75`)"
|
||
)
|
||
return
|
||
|
||
ext = parts[1]
|
||
employee = schedule.get_employee_by_extension(ext)
|
||
if not employee:
|
||
respond(text=f"Extension `{ext}` not found in the roster.")
|
||
return
|
||
|
||
try:
|
||
amount = float(parts[2].replace("$", ""))
|
||
except ValueError:
|
||
respond(text=f"Invalid amount: `{parts[2]}`")
|
||
return
|
||
|
||
schedule.set_employee_shift_rate(ext, amount)
|
||
respond(
|
||
text=f"Shift rate for *{employee['name']}* (Ext {ext}) set to *${amount:.2f}*."
|
||
)
|
||
|
||
|
||
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()
|
||
try:
|
||
employee = schedule.register_user(user_id, ext)
|
||
except ExtensionAlreadyRegistered:
|
||
respond(
|
||
text=(
|
||
f"Extension {ext} is already registered to another person. "
|
||
"If this is your extension, ask an admin to clear it."
|
||
)
|
||
)
|
||
return
|
||
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, schedule_channel
|
||
):
|
||
parts = text.split()
|
||
if len(parts) < 2:
|
||
respond(
|
||
text="Usage: `/oncall pick <date> [day|night]` (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
|
||
|
||
explicit_shift = (
|
||
parts[2] if len(parts) > 2 and parts[2] in ("day", "night") else None
|
||
)
|
||
date_text = parts[1]
|
||
date = parse_date(date_text)
|
||
if not date:
|
||
respond(
|
||
text=f"Couldn't parse date: `{date_text}`. Try: today, tomorrow, friday, 4/5, 2026-04-05"
|
||
)
|
||
return
|
||
|
||
date_str = date.strftime("%Y-%m-%d")
|
||
if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"):
|
||
respond(text="You can't pick up a shift in the past.")
|
||
return
|
||
|
||
day_name = date.strftime("%A")
|
||
shift_type = _resolve_pick_shift_type(schedule, date_str, day_name, explicit_shift)
|
||
ctx = schedule.get_shift_context(date_str, day_name, shift_type)
|
||
|
||
if ctx["kind"] == "holiday":
|
||
_pick_holiday(
|
||
respond,
|
||
schedule,
|
||
employee,
|
||
date,
|
||
date_str,
|
||
ctx,
|
||
channel_id,
|
||
client,
|
||
schedule_channel,
|
||
)
|
||
return
|
||
|
||
_pick_regular(
|
||
respond,
|
||
schedule,
|
||
employee,
|
||
date,
|
||
date_str,
|
||
day_name,
|
||
shift_type,
|
||
ctx,
|
||
channel_id,
|
||
client,
|
||
schedule_channel,
|
||
)
|
||
|
||
|
||
def _resolve_pick_shift_type(schedule, date_str, day_name, explicit_shift) -> str:
|
||
"""Pick the shift type a bare ``/oncall pick <date>`` should target.
|
||
|
||
Honours an explicit ``day``/``night``. Otherwise a holiday (day-only) wins,
|
||
then any open day/night slot on a weekend; falls back to ``night``.
|
||
"""
|
||
if explicit_shift:
|
||
return explicit_shift
|
||
if schedule.get_shift_context(date_str, day_name, "day")["kind"] == "holiday":
|
||
return "day"
|
||
if day_name in WEEKEND_DAYS:
|
||
for st in ("day", "night"):
|
||
_ext, _name, source = schedule.resolve_shift(date_str, day_name, st)
|
||
if source == "available":
|
||
return st
|
||
return "night"
|
||
|
||
|
||
def _pick_regular(
|
||
respond,
|
||
schedule,
|
||
employee,
|
||
date,
|
||
date_str,
|
||
day_name,
|
||
shift_type,
|
||
ctx,
|
||
channel_id,
|
||
client,
|
||
schedule_channel,
|
||
):
|
||
"""Pick up a regular (non-holiday) shift, gating late pickups on approval."""
|
||
assignees = ctx["assignees"]
|
||
# Already assigned to someone else (not open).
|
||
if assignees and assignees[0]["extension"] != employee["extension"]:
|
||
name = assignees[0]["name"]
|
||
ext = assignees[0]["extension"]
|
||
respond(
|
||
text=f"That shift is already covered by {name} (Ext {ext}). They'd need to drop it first."
|
||
)
|
||
return
|
||
|
||
date_label = date.strftime("%A, %b %-d")
|
||
shift_label = _shift_type_label(day_name, shift_type)
|
||
|
||
# A pickup after the shift has started (but before it ends) needs an admin to
|
||
# approve it. After the shift ends it's too late to pick up at all.
|
||
if _shift_ended(date_str, shift_type):
|
||
respond(text=f"The *{date_label}*{shift_label} shift has already ended.")
|
||
return
|
||
if _shift_started(date_str, shift_type):
|
||
_request_late_pickup(
|
||
respond,
|
||
schedule,
|
||
employee,
|
||
date_str,
|
||
shift_type,
|
||
is_holiday=False,
|
||
client=client,
|
||
)
|
||
return
|
||
|
||
# Already this employee's own shift — nothing to claim, just confirm.
|
||
already_mine = (
|
||
bool(assignees) and assignees[0]["extension"] == employee["extension"]
|
||
)
|
||
if not already_mine:
|
||
# Atomic conditional claim: only one of two concurrent pickers wins, so
|
||
# the loser is told it's taken instead of silently overwriting (TOCTOU).
|
||
claimed = schedule.claim_open_shift(
|
||
date_str, employee["extension"], employee["name"], shift_type
|
||
)
|
||
if not claimed:
|
||
respond(
|
||
text=(
|
||
f"The *{date_label}*{shift_label} shift was just picked up by "
|
||
"someone else."
|
||
)
|
||
)
|
||
return
|
||
|
||
if is_today(date_str) and _is_active_shift_type(shift_type):
|
||
_update_3cx_routing(employee["extension"])
|
||
|
||
respond(text=f"You picked up the shift for *{date_label}*{shift_label}.")
|
||
|
||
blocks = build_shift_change_message(
|
||
user_id=employee.get("slack_user_id", ""),
|
||
date_str=date_str,
|
||
action="picked_up",
|
||
ext=employee["extension"],
|
||
name=employee["name"],
|
||
shift_type=shift_type,
|
||
)
|
||
try:
|
||
client.chat_postMessage(
|
||
channel=channel_id,
|
||
blocks=blocks,
|
||
text=f"Shift picked up for {date_str}",
|
||
)
|
||
except Exception:
|
||
logger.exception("Failed to post pickup notification to channel")
|
||
_refresh_schedule_post(schedule, schedule_channel, client)
|
||
|
||
|
||
def _pick_holiday(
|
||
respond,
|
||
schedule,
|
||
employee,
|
||
date,
|
||
date_str,
|
||
ctx,
|
||
channel_id,
|
||
client,
|
||
schedule_channel,
|
||
):
|
||
"""Pick up a holiday day-shift slot, gating late pickups on approval.
|
||
|
||
Holidays support multiple concurrent assignees; the claim is atomic via
|
||
``claim_holiday_slot`` so concurrent pickers can't oversubscribe the slots.
|
||
"""
|
||
ext = employee["extension"]
|
||
date_label = date.strftime("%A, %b %-d")
|
||
label = ctx.get("label") or "Holiday"
|
||
|
||
if any(a["extension"] == ext for a in ctx["assignees"]):
|
||
respond(text=f"You're already on the *{date_label}* ({label}) holiday shift.")
|
||
return
|
||
|
||
# Late pickup (window open) or too late (window closed).
|
||
if _shift_ended(date_str, "day"):
|
||
respond(text=f"The *{date_label}* ({label}) holiday shift has already ended.")
|
||
return
|
||
if _shift_started(date_str, "day"):
|
||
_request_late_pickup(
|
||
respond,
|
||
schedule,
|
||
employee,
|
||
date_str,
|
||
"day",
|
||
is_holiday=True,
|
||
client=client,
|
||
)
|
||
return
|
||
|
||
claimed = schedule.claim_holiday_slot(date_str, ext, employee["name"])
|
||
if not claimed:
|
||
respond(
|
||
text=(
|
||
f"Couldn't claim a slot on the *{date_label}* ({label}) holiday — "
|
||
"it's full or you're already on it."
|
||
)
|
||
)
|
||
return
|
||
|
||
respond(text=f"You picked up a slot on the *{date_label}* ({label}) holiday shift.")
|
||
|
||
blocks = build_shift_change_message(
|
||
user_id=employee.get("slack_user_id", ""),
|
||
date_str=date_str,
|
||
action="picked_up",
|
||
ext=ext,
|
||
name=employee["name"],
|
||
shift_type="day",
|
||
)
|
||
try:
|
||
client.chat_postMessage(
|
||
channel=channel_id,
|
||
blocks=blocks,
|
||
text=f"Holiday shift picked up for {date_str}",
|
||
)
|
||
except Exception:
|
||
logger.exception("Failed to post holiday pickup notification to channel")
|
||
_refresh_schedule_post(schedule, schedule_channel, client)
|
||
|
||
|
||
def _handle_drop(
|
||
respond, schedule, user_id, text, channel_id, client, schedule_channel
|
||
):
|
||
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")
|
||
if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"):
|
||
respond(text="You can't drop a shift in the past.")
|
||
return
|
||
|
||
day_name = date.strftime("%A")
|
||
|
||
# A holiday slot the employee holds is dropped (released) ahead of regular
|
||
# day/night shifts — holidays take priority on their date.
|
||
holiday_ctx = schedule.get_shift_context(date_str, day_name, "day")
|
||
if holiday_ctx["kind"] == "holiday" and any(
|
||
a["extension"] == employee["extension"] for a in holiday_ctx["assignees"]
|
||
):
|
||
_drop_holiday(
|
||
respond,
|
||
schedule,
|
||
employee,
|
||
date,
|
||
date_str,
|
||
channel_id,
|
||
client,
|
||
schedule_channel,
|
||
)
|
||
return
|
||
|
||
found = _find_employee_shift(schedule, date_str, day_name, employee["extension"])
|
||
|
||
if not found:
|
||
ext, name, _source = schedule.resolve_shift(date_str, day_name)
|
||
respond(text=f"That's not your shift — it belongs to {name} (Ext {ext}).")
|
||
return
|
||
|
||
ext, name, source, shift_type = found
|
||
|
||
if _within_drop_lock(date_str, shift_type):
|
||
respond(
|
||
text=(
|
||
"This shift starts in under 24 hours — you can't drop it now. "
|
||
"Hand it off with `/oncall swap <date> @person` (they'll need to accept), "
|
||
"or ask an admin to open it."
|
||
)
|
||
)
|
||
return
|
||
|
||
# No 3CX repoint here: a same-day shift is always inside the 24h lock above,
|
||
# so a drop that reaches this point is never today's active shift.
|
||
schedule.mark_open(date_str, shift_type)
|
||
|
||
date_label = date.strftime("%A, %b %-d")
|
||
shift_label = _shift_type_label(day_name, shift_type)
|
||
respond(
|
||
text=(
|
||
f"You dropped the shift for *{date_label}*{shift_label}. "
|
||
"It's now open for pickup."
|
||
)
|
||
)
|
||
|
||
blocks = build_shift_change_message(
|
||
user_id, date_str, "dropped", ext, name, shift_type=shift_type
|
||
)
|
||
try:
|
||
client.chat_postMessage(
|
||
channel=channel_id, blocks=blocks, text=f"Shift dropped for {date_str}"
|
||
)
|
||
except Exception:
|
||
logger.exception("Failed to post drop notification to channel")
|
||
_refresh_schedule_post(schedule, schedule_channel, client)
|
||
|
||
|
||
def _drop_holiday(
|
||
respond, schedule, employee, date, date_str, channel_id, client, schedule_channel
|
||
):
|
||
"""Release the employee's holiday slot (subject to the 24h drop lock).
|
||
|
||
The slot becomes open for someone else to pick up. The released slot does
|
||
not repoint 3CX here: a same-day drop is always inside the 24h lock (the
|
||
holiday starts 08:00 ET), so a drop that reaches the release is never the
|
||
live window.
|
||
"""
|
||
if _within_drop_lock(date_str, "day"):
|
||
respond(
|
||
text=(
|
||
"This holiday shift starts in under 24 hours — you can't drop it now. "
|
||
"Hand it off with `/oncall swap <date> @person` (they'll need to accept), "
|
||
"or ask an admin to open it."
|
||
)
|
||
)
|
||
return
|
||
|
||
released = schedule.release_holiday_slot(date_str, employee["extension"])
|
||
if not released:
|
||
respond(text="You're not on that holiday shift.")
|
||
return
|
||
|
||
date_label = date.strftime("%A, %b %-d")
|
||
respond(
|
||
text=(
|
||
f"You dropped your slot on the *{date_label}* holiday shift. "
|
||
"It's now open for pickup."
|
||
)
|
||
)
|
||
|
||
blocks = build_shift_change_message(
|
||
user_id=employee.get("slack_user_id", ""),
|
||
date_str=date_str,
|
||
action="dropped",
|
||
ext=employee["extension"],
|
||
name=employee["name"],
|
||
shift_type="day",
|
||
)
|
||
try:
|
||
client.chat_postMessage(
|
||
channel=channel_id,
|
||
blocks=blocks,
|
||
text=f"Holiday shift dropped for {date_str}",
|
||
)
|
||
except Exception:
|
||
logger.exception("Failed to post holiday drop notification to channel")
|
||
_refresh_schedule_post(schedule, schedule_channel, client)
|
||
|
||
|
||
def _handle_swap(
|
||
respond, schedule, user_id, text, channel_id, client, schedule_channel
|
||
):
|
||
# 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")
|
||
if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"):
|
||
respond(text="You can't swap a shift in the past.")
|
||
return
|
||
|
||
day_name = date.strftime("%A")
|
||
|
||
# A holiday slot the employee holds is swappable as a day shift; holidays
|
||
# take priority on their date.
|
||
holiday_ctx = schedule.get_shift_context(date_str, day_name, "day")
|
||
holiday_swap = holiday_ctx["kind"] == "holiday" and any(
|
||
a["extension"] == employee["extension"] for a in holiday_ctx["assignees"]
|
||
)
|
||
|
||
if holiday_swap:
|
||
shift_type = "day"
|
||
else:
|
||
found = _find_employee_shift(
|
||
schedule, date_str, day_name, employee["extension"]
|
||
)
|
||
if not found:
|
||
ext, name, _source = schedule.resolve_shift(date_str, day_name)
|
||
respond(
|
||
text=f"That's not your shift — it belongs to {name} (Ext {ext}). You can only swap your own shifts."
|
||
)
|
||
return
|
||
_ext, _name, _source, shift_type = found
|
||
|
||
# Resolve target user — could be <@U12345> or an extension number
|
||
target_text = parts[2].strip()
|
||
slack_id_match = re.match(r"<@(\w+)(?:\|[^>]*)?>", target_text)
|
||
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
|
||
|
||
if target["extension"] == employee["extension"]:
|
||
respond(text="That shift is already yours — nothing to swap.")
|
||
return
|
||
|
||
# The target must be linked to Slack so we can DM them the request.
|
||
if not target.get("slack_user_id"):
|
||
respond(
|
||
text=f"*{target['name']}* (Ext {target['extension']}) isn't linked to Slack yet — "
|
||
"they need to run `/oncall register <extension>` before they can be swapped a shift."
|
||
)
|
||
return
|
||
|
||
# Create a pending swap and DM the target Accept/Decline. The shift does NOT
|
||
# move until they accept — the original owner stays responsible until then.
|
||
expires_at = int(_shift_start(date_str, shift_type).timestamp())
|
||
schedule.create_pending_swap(date_str, shift_type, employee, target, expires_at)
|
||
|
||
date_label = date.strftime("%A, %b %-d")
|
||
shift_label = _shift_type_label(day_name, shift_type)
|
||
try:
|
||
client.chat_postMessage(
|
||
channel=target["slack_user_id"],
|
||
blocks=build_swap_request_blocks(user_id, date_str, shift_type),
|
||
text=f"{employee['name']} wants to swap you the {date_str} shift",
|
||
)
|
||
except Exception:
|
||
logger.exception("Failed to DM swap request to target")
|
||
respond(
|
||
text=f"Couldn't reach *{target['name']}* on Slack to send the request. Try again later."
|
||
)
|
||
return
|
||
|
||
respond(
|
||
text=(
|
||
f"Swap request sent to <@{target['slack_user_id']}> for "
|
||
f"*{date_label}*{shift_label}. The shift moves to them once they accept."
|
||
)
|
||
)
|
||
|
||
|
||
def _parse_swap_action(action_id: str, prefix: str) -> tuple[str, str]:
|
||
"""Split a swap action_id into (date_str, shift_type)."""
|
||
remainder = action_id[len(prefix) :]
|
||
if remainder.endswith("_day"):
|
||
return remainder[:-4], "day"
|
||
return remainder, "night"
|
||
|
||
|
||
def handle_swap_accept(body, respond, client, schedule, schedule_channel):
|
||
"""Target accepted a swap — apply the override and mark it verified."""
|
||
date_str, shift_type = _parse_swap_action(
|
||
body["actions"][0]["action_id"], "swap_accept_"
|
||
)
|
||
user_id = body["user"]["id"]
|
||
|
||
swap = schedule.get_swap(date_str, shift_type)
|
||
if (
|
||
not swap
|
||
or swap.get("status") != "pending"
|
||
or swap.get("target_slack") != user_id
|
||
):
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_swap_resolved_blocks("This swap request is no longer valid."),
|
||
)
|
||
return
|
||
|
||
if _shift_started(date_str, shift_type):
|
||
schedule.clear_swap(date_str, shift_type)
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_swap_resolved_blocks(
|
||
"This swap request has expired — the shift has already started."
|
||
),
|
||
)
|
||
return
|
||
|
||
# A day-shift swap on a holiday date hands off a holiday slot rather than a
|
||
# plain override: atomically move the slot from requester to target.
|
||
is_holiday = shift_type == "day" and schedule.get_holiday(date_str) is not None
|
||
if is_holiday:
|
||
moved = schedule.swap_holiday_assignee(
|
||
date_str,
|
||
swap["requester_ext"],
|
||
swap["target_ext"],
|
||
swap["target_name"],
|
||
)
|
||
if not moved:
|
||
schedule.clear_swap(date_str, shift_type)
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_swap_resolved_blocks(
|
||
"This swap request is no longer valid."
|
||
),
|
||
)
|
||
return
|
||
if _holiday_window_active(date_str):
|
||
_set_holiday_queue_agents(schedule, date_str)
|
||
else:
|
||
# Re-resolve the current holder at accept time. reassign_if_held_by
|
||
# below trusts "no override row" as "still the requester's", but a
|
||
# weekly-held shift also has no override row — so an admin clear or a
|
||
# weekly-schedule edit between swap-init and accept could move the shift
|
||
# to a third party without ever creating an override, and the bare
|
||
# conditional write would not catch it. Confirm the requester is still
|
||
# the resolved holder before reassigning.
|
||
accept_day_name = datetime.strptime(date_str, "%Y-%m-%d").strftime("%A")
|
||
current_ext, _, _ = schedule.resolve_shift(
|
||
date_str, accept_day_name, shift_type
|
||
)
|
||
if current_ext != swap["requester_ext"]:
|
||
schedule.clear_swap(date_str, shift_type)
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_swap_resolved_blocks(
|
||
"This shift is no longer assigned to the person who "
|
||
"requested the swap, so it couldn't be applied."
|
||
),
|
||
)
|
||
return
|
||
# Only move the shift if it's still the requester's (or still on the
|
||
# weekly fallback). If it was independently claimed after the swap was
|
||
# initiated, abort instead of silently overwriting the new holder.
|
||
moved = schedule.reassign_if_held_by(
|
||
date_str,
|
||
swap["requester_ext"],
|
||
swap["target_ext"],
|
||
swap["target_name"],
|
||
shift_type,
|
||
)
|
||
if not moved:
|
||
schedule.clear_swap(date_str, shift_type)
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_swap_resolved_blocks(
|
||
"This shift was already picked up by someone else, so the "
|
||
"swap couldn't be applied."
|
||
),
|
||
)
|
||
return
|
||
if is_today(date_str) and _is_active_shift_type(shift_type):
|
||
_update_3cx_routing(swap["target_ext"])
|
||
schedule.mark_swap_verified(date_str, shift_type)
|
||
|
||
day_name = datetime.strptime(date_str, "%Y-%m-%d").strftime("%A")
|
||
date_label = datetime.strptime(date_str, "%Y-%m-%d").strftime("%A, %b %-d")
|
||
shift_label = _shift_type_label(day_name, shift_type)
|
||
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_swap_resolved_blocks(
|
||
f"You're now covering the *{date_label}*{shift_label} shift. Thanks!"
|
||
),
|
||
)
|
||
|
||
if swap.get("requester_slack"):
|
||
try:
|
||
client.chat_postMessage(
|
||
channel=swap["requester_slack"],
|
||
text=f"<@{user_id}> accepted your swap — they're now on the *{date_label}*{shift_label} shift.",
|
||
)
|
||
except Exception:
|
||
logger.exception("Failed to DM swap requester on accept")
|
||
|
||
if schedule_channel:
|
||
blocks = build_shift_change_message(
|
||
user_id,
|
||
date_str,
|
||
"swapped",
|
||
swap["target_ext"],
|
||
swap["target_name"],
|
||
shift_type=shift_type,
|
||
)
|
||
try:
|
||
client.chat_postMessage(
|
||
channel=schedule_channel,
|
||
blocks=blocks,
|
||
text=f"Shift swapped for {date_str}",
|
||
)
|
||
except Exception:
|
||
logger.exception("Failed to post swap notification to channel")
|
||
_refresh_schedule_post(schedule, schedule_channel, client)
|
||
|
||
|
||
def handle_swap_decline(body, respond, client, schedule, schedule_channel):
|
||
"""Target declined a swap — clear it and notify the requester."""
|
||
date_str, shift_type = _parse_swap_action(
|
||
body["actions"][0]["action_id"], "swap_decline_"
|
||
)
|
||
user_id = body["user"]["id"]
|
||
|
||
swap = schedule.get_swap(date_str, shift_type)
|
||
if (
|
||
not swap
|
||
or swap.get("status") != "pending"
|
||
or swap.get("target_slack") != user_id
|
||
):
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_swap_resolved_blocks("This swap request is no longer valid."),
|
||
)
|
||
return
|
||
|
||
schedule.clear_swap(date_str, shift_type)
|
||
|
||
day_name = datetime.strptime(date_str, "%Y-%m-%d").strftime("%A")
|
||
date_label = datetime.strptime(date_str, "%Y-%m-%d").strftime("%A, %b %-d")
|
||
shift_label = _shift_type_label(day_name, shift_type)
|
||
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_swap_resolved_blocks(
|
||
f"You declined the *{date_label}*{shift_label} swap. No change."
|
||
),
|
||
)
|
||
|
||
if swap.get("requester_slack"):
|
||
try:
|
||
client.chat_postMessage(
|
||
channel=swap["requester_slack"],
|
||
text=f"<@{user_id}> declined your swap for *{date_label}*{shift_label} — it's still your shift.",
|
||
)
|
||
except Exception:
|
||
logger.exception("Failed to DM swap requester on decline")
|
||
|
||
|
||
# ── Late-pickup approval ─────────────────────────────────────────────────
|
||
|
||
|
||
def _request_late_pickup(
|
||
respond, schedule, employee, date_str, shift_type, is_holiday, client
|
||
):
|
||
"""Create a PICKUP_REQUEST and DM every admin an Approve/Deny prompt.
|
||
|
||
Used when someone tries to pick up a shift that has already *started* (but
|
||
not ended). The shift is NOT claimed yet — the first admin to approve wins
|
||
(a conditional claim). Mirrors the verified-swap DM pattern, but the
|
||
approvers are the admins rather than the swap target.
|
||
"""
|
||
schedule.create_pickup_request(
|
||
date_str,
|
||
shift_type,
|
||
employee,
|
||
expires_at=int(_shift_end(date_str, shift_type).timestamp()),
|
||
is_holiday=is_holiday,
|
||
)
|
||
|
||
blocks = build_pickup_request_blocks(
|
||
requester_slack=employee.get("slack_user_id", ""),
|
||
requester_name=employee["name"],
|
||
date_str=date_str,
|
||
shift_type=shift_type,
|
||
requester_ext=employee["extension"],
|
||
is_holiday=is_holiday,
|
||
)
|
||
text = f"{employee['name']} wants to pick up the already-started {date_str} shift"
|
||
admins = schedule.get_admin_users()
|
||
delivered = 0
|
||
for admin_id in admins:
|
||
try:
|
||
client.chat_postMessage(channel=admin_id, blocks=blocks, text=text)
|
||
delivered += 1
|
||
except Exception:
|
||
logger.exception("Failed to DM late-pickup request to admin %s", admin_id)
|
||
|
||
if delivered == 0:
|
||
# Nobody to approve it — roll the request back so it doesn't dangle.
|
||
schedule.clear_pickup_request(date_str, shift_type, employee["extension"])
|
||
respond(
|
||
text=(
|
||
"That shift has already started and needs an admin to approve a "
|
||
"pickup, but I couldn't reach any admin. Please contact one directly."
|
||
)
|
||
)
|
||
return
|
||
|
||
respond(
|
||
text=(
|
||
"That shift has already started, so a pickup needs admin approval. "
|
||
"I've sent your request to the admins — you'll be notified once it's decided."
|
||
)
|
||
)
|
||
|
||
|
||
def _parse_pickup_action(action_id: str, prefix: str) -> tuple[str, str, str]:
|
||
"""Split a pickup-approval action_id into (date_str, shift_type, ext).
|
||
|
||
The tail is ``<date>[_day]_<ext>`` (see ``build_pickup_request_blocks``).
|
||
"""
|
||
remainder = action_id[len(prefix) :]
|
||
ext_start = remainder.rfind("_")
|
||
ext = remainder[ext_start + 1 :]
|
||
rest = remainder[:ext_start]
|
||
if rest.endswith("_day"):
|
||
return rest[:-4], "day", ext
|
||
return rest, "night", ext
|
||
|
||
|
||
def handle_pickup_approve(body, respond, client, schedule, schedule_channel):
|
||
"""An admin approved a late pickup — claim the shift for the requester.
|
||
|
||
First-approve-wins: the claim is conditional (``set_override`` after a guard,
|
||
or atomic ``claim_holiday_slot``), so a second admin approving a
|
||
just-resolved request gets a "no longer pending" response. On success the
|
||
requester is DM'd and 3CX is repointed if the shift window is currently live.
|
||
"""
|
||
date_str, shift_type, ext = _parse_pickup_action(
|
||
body["actions"][0]["action_id"], "pickup_approve_"
|
||
)
|
||
admin_id = body["user"]["id"]
|
||
# The Approve button is DM'd only to admins, but action handlers receive
|
||
# whoever clicks — re-verify the actor is an admin (mirrors swap handlers).
|
||
if admin_id not in schedule.get_admin_users():
|
||
respond(
|
||
replace_original=False,
|
||
text="Only an admin can approve a pickup request.",
|
||
)
|
||
return
|
||
|
||
req = schedule.get_pickup_request(date_str, shift_type, ext)
|
||
if not req or req.get("status") != "pending":
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_pickup_resolved_blocks(
|
||
"This pickup request is no longer pending."
|
||
),
|
||
)
|
||
return
|
||
|
||
if _shift_ended(date_str, shift_type):
|
||
schedule.clear_pickup_request(date_str, shift_type, ext)
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_pickup_resolved_blocks(
|
||
"This pickup request has expired — the shift has already ended."
|
||
),
|
||
)
|
||
return
|
||
|
||
# Atomic first-approve-wins: only the admin who flips the request
|
||
# pending->approved proceeds; a second concurrent approver is turned away.
|
||
if not schedule.approve_pickup_request(date_str, shift_type, ext):
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_pickup_resolved_blocks(
|
||
"This pickup request is no longer pending."
|
||
),
|
||
)
|
||
return
|
||
|
||
requester_name = req.get("requester_name", ext)
|
||
requester_slack = req.get("requester_slack", "")
|
||
is_holiday = bool(req.get("is_holiday"))
|
||
|
||
day_name = datetime.strptime(date_str, "%Y-%m-%d").strftime("%A")
|
||
date_label = datetime.strptime(date_str, "%Y-%m-%d").strftime("%A, %b %-d")
|
||
shift_label = _shift_type_label(day_name, shift_type)
|
||
|
||
if is_holiday:
|
||
claimed = schedule.claim_holiday_slot(date_str, ext, requester_name)
|
||
if not claimed:
|
||
schedule.clear_pickup_request(date_str, shift_type, ext)
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_pickup_resolved_blocks(
|
||
"Couldn't assign the holiday slot — it's full or already taken."
|
||
),
|
||
)
|
||
return
|
||
if _holiday_window_active(date_str):
|
||
_set_holiday_queue_agents(schedule, date_str)
|
||
else:
|
||
# Conditional claim so two different requesters' approvals for the SAME
|
||
# shift can't silently clobber each other — the per-request approve gate
|
||
# only serializes a single request's SK, not the OVERRIDE row.
|
||
claimed = schedule.claim_open_shift(date_str, ext, requester_name, shift_type)
|
||
if not claimed:
|
||
schedule.clear_pickup_request(date_str, shift_type, ext)
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_pickup_resolved_blocks(
|
||
"Couldn't assign the shift — it's already covered."
|
||
),
|
||
)
|
||
return
|
||
if is_today(date_str) and _is_active_shift_type(shift_type):
|
||
# Best-effort: a 3CX failure must not strand the request as approved.
|
||
try:
|
||
_update_3cx_routing(ext)
|
||
except Exception:
|
||
logger.exception("3CX repoint failed after approving late pickup")
|
||
|
||
schedule.clear_pickup_request(date_str, shift_type, ext)
|
||
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_pickup_resolved_blocks(
|
||
f"Approved — {requester_name} (Ext {ext}) is now on the "
|
||
f"*{date_label}*{shift_label} shift."
|
||
),
|
||
)
|
||
|
||
if requester_slack:
|
||
try:
|
||
client.chat_postMessage(
|
||
channel=requester_slack,
|
||
text=(
|
||
f"<@{admin_id}> approved your pickup — you're now on the "
|
||
f"*{date_label}*{shift_label} shift."
|
||
),
|
||
)
|
||
except Exception:
|
||
logger.exception("Failed to DM late-pickup requester on approve")
|
||
|
||
if schedule_channel:
|
||
try:
|
||
client.chat_postMessage(
|
||
channel=schedule_channel,
|
||
blocks=build_shift_change_message(
|
||
user_id=requester_slack,
|
||
date_str=date_str,
|
||
action="picked_up",
|
||
ext=ext,
|
||
name=requester_name,
|
||
shift_type=shift_type,
|
||
),
|
||
text=f"Shift picked up for {date_str}",
|
||
)
|
||
except Exception:
|
||
logger.exception("Failed to post late-pickup notification to channel")
|
||
_refresh_schedule_post(schedule, schedule_channel, client)
|
||
|
||
|
||
def handle_pickup_deny(body, respond, client, schedule, schedule_channel):
|
||
"""An admin denied a late pickup — clear the request and notify the requester."""
|
||
date_str, shift_type, ext = _parse_pickup_action(
|
||
body["actions"][0]["action_id"], "pickup_deny_"
|
||
)
|
||
admin_id = body["user"]["id"]
|
||
if admin_id not in schedule.get_admin_users():
|
||
respond(
|
||
replace_original=False,
|
||
text="Only an admin can deny a pickup request.",
|
||
)
|
||
return
|
||
|
||
req = schedule.get_pickup_request(date_str, shift_type, ext)
|
||
if not req or req.get("status") != "pending":
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_pickup_resolved_blocks(
|
||
"This pickup request is no longer pending."
|
||
),
|
||
)
|
||
return
|
||
|
||
schedule.clear_pickup_request(date_str, shift_type, ext)
|
||
|
||
day_name = datetime.strptime(date_str, "%Y-%m-%d").strftime("%A")
|
||
date_label = datetime.strptime(date_str, "%Y-%m-%d").strftime("%A, %b %-d")
|
||
shift_label = _shift_type_label(day_name, shift_type)
|
||
|
||
respond(
|
||
replace_original=True,
|
||
blocks=build_pickup_resolved_blocks(
|
||
f"Denied — the *{date_label}*{shift_label} pickup was not approved."
|
||
),
|
||
)
|
||
|
||
requester_slack = req.get("requester_slack", "")
|
||
if requester_slack:
|
||
try:
|
||
client.chat_postMessage(
|
||
channel=requester_slack,
|
||
text=(
|
||
f"<@{admin_id}> denied your pickup for the "
|
||
f"*{date_label}*{shift_label} shift."
|
||
),
|
||
)
|
||
except Exception:
|
||
logger.exception("Failed to DM late-pickup requester on deny")
|
||
|
||
|
||
def _handle_admin(respond, schedule, user_id, text, is_admin, client, schedule_channel):
|
||
if not is_admin:
|
||
respond(text="Admin commands are restricted. Contact an administrator.")
|
||
return
|
||
|
||
parts = text.split()
|
||
if len(parts) < 2:
|
||
respond(
|
||
text=(
|
||
"*Admin Commands:*\n"
|
||
"`admin override <date> <ext> [day|night]` — Assign shift\n"
|
||
"`admin open <date> [day|night]` — Mark open\n"
|
||
"`admin clear <date> [day|night]` — Remove override\n"
|
||
"`admin roster add <ext> <name>` — Add employee\n"
|
||
"`admin roster remove <ext>` — Remove employee\n"
|
||
"`admin roster rename <ext> <name>` — Rename\n"
|
||
"`admin holiday add <date> <slots> [x<mult>] <label>` — Schedule holiday\n"
|
||
"`admin holiday remove <date>` — Remove holiday\n"
|
||
"`admin holiday list` — List upcoming holidays"
|
||
)
|
||
)
|
||
return
|
||
|
||
subcmd = parts[1]
|
||
|
||
if subcmd == "override":
|
||
if len(parts) < 4:
|
||
respond(
|
||
text="Usage: `/oncall admin override <date> <extension> [day|night]`"
|
||
)
|
||
return
|
||
date = parse_date(parts[2])
|
||
if not date:
|
||
respond(text=f"Couldn't parse date: `{parts[2]}`")
|
||
return
|
||
ext = parts[3]
|
||
shift_type = (
|
||
parts[4] if len(parts) > 4 and parts[4] in ("day", "night") else "night"
|
||
)
|
||
employee = schedule.get_employee_by_extension(ext)
|
||
if not employee:
|
||
respond(text=f"Extension `{ext}` not found in the roster.")
|
||
return
|
||
date_str = date.strftime("%Y-%m-%d")
|
||
schedule.set_override(
|
||
date_str, employee["extension"], employee["name"], shift_type
|
||
)
|
||
if is_today(date_str) and _is_active_shift_type(shift_type):
|
||
_update_3cx_routing(employee["extension"])
|
||
label = "Day" if shift_type == "day" else "Night"
|
||
respond(
|
||
text=f"Override set: *{date.strftime('%A, %b %-d')}* ({label}) → {employee['name']} (Ext {ext})"
|
||
)
|
||
_refresh_schedule_post(schedule, schedule_channel, client)
|
||
|
||
elif subcmd == "open":
|
||
if len(parts) < 3:
|
||
respond(text="Usage: `/oncall admin open <date> [day|night]`")
|
||
return
|
||
date = parse_date(parts[2])
|
||
if not date:
|
||
respond(text=f"Couldn't parse date: `{parts[2]}`")
|
||
return
|
||
shift_type = (
|
||
parts[3] if len(parts) > 3 and parts[3] in ("day", "night") else "night"
|
||
)
|
||
date_str = date.strftime("%Y-%m-%d")
|
||
schedule.mark_open(date_str, shift_type)
|
||
if is_today(date_str) and _is_active_shift_type(shift_type):
|
||
_update_3cx_routing(FALLBACK_EXTENSION)
|
||
label = "Day" if shift_type == "day" else "Night"
|
||
respond(text=f"*{date.strftime('%A, %b %-d')}* ({label}) marked as open.")
|
||
_refresh_schedule_post(schedule, schedule_channel, client)
|
||
|
||
elif subcmd == "clear":
|
||
if len(parts) < 3:
|
||
respond(text="Usage: `/oncall admin clear <date> [day|night]`")
|
||
return
|
||
date = parse_date(parts[2])
|
||
if not date:
|
||
respond(text=f"Couldn't parse date: `{parts[2]}`")
|
||
return
|
||
shift_type = (
|
||
parts[3] if len(parts) > 3 and parts[3] in ("day", "night") else "night"
|
||
)
|
||
date_str = date.strftime("%Y-%m-%d")
|
||
schedule.remove_override(date_str, shift_type)
|
||
if is_today(date_str) and _is_active_shift_type(shift_type):
|
||
day_name = date.strftime("%A")
|
||
ext, _name, _source = schedule.resolve_shift(date_str, day_name, shift_type)
|
||
_update_3cx_routing(ext)
|
||
label = "Day" if shift_type == "day" else "Night"
|
||
respond(
|
||
text=f"Override cleared for *{date.strftime('%A, %b %-d')}* ({label}) — reverted to weekly schedule."
|
||
)
|
||
_refresh_schedule_post(schedule, schedule_channel, client)
|
||
|
||
elif subcmd == "roster":
|
||
if len(parts) < 3:
|
||
respond(text="Usage: `admin roster add|remove|rename <ext> [name]`")
|
||
return
|
||
roster_cmd = parts[2]
|
||
|
||
if roster_cmd == "add":
|
||
if len(parts) < 5:
|
||
respond(text="Usage: `/oncall admin roster add <ext> <name>`")
|
||
return
|
||
ext = parts[3]
|
||
name = " ".join(parts[4:])
|
||
added = schedule.add_roster_entry(ext, name)
|
||
if not added:
|
||
respond(
|
||
text=f"Extension `{ext}` already exists. Use `roster rename` to change the name."
|
||
)
|
||
return
|
||
respond(text=f"Added *{name}* (Ext {ext}) to the roster.")
|
||
|
||
elif roster_cmd == "remove":
|
||
if len(parts) < 4:
|
||
respond(text="Usage: `/oncall admin roster remove <ext>`")
|
||
return
|
||
ext = parts[3]
|
||
employee = schedule.get_employee_by_extension(ext)
|
||
if not employee:
|
||
respond(text=f"Extension `{ext}` not found in the roster.")
|
||
return
|
||
schedule.remove_roster_entry(ext)
|
||
respond(
|
||
text=f"Removed *{employee.get('name', ext)}* (Ext {ext}) from the roster."
|
||
)
|
||
|
||
elif roster_cmd == "rename":
|
||
if len(parts) < 5:
|
||
respond(text="Usage: `/oncall admin roster rename <ext> <name>`")
|
||
return
|
||
ext = parts[3]
|
||
employee = schedule.get_employee_by_extension(ext)
|
||
if not employee:
|
||
respond(text=f"Extension `{ext}` not found in the roster.")
|
||
return
|
||
new_name = " ".join(parts[4:])
|
||
schedule.rename_roster_entry(ext, new_name)
|
||
respond(
|
||
text=f"Renamed Ext {ext}: {employee.get('name', '?')} → *{new_name}*"
|
||
)
|
||
|
||
else:
|
||
respond(text="Unknown roster command. Use `add`, `remove`, or `rename`.")
|
||
|
||
elif subcmd == "holiday":
|
||
_handle_admin_holiday(respond, schedule, parts, client, schedule_channel)
|
||
|
||
else:
|
||
respond(text=f"Unknown admin command: `{subcmd}`. Try `/oncall help`.")
|
||
|
||
|
||
# ── Admin: holidays ──────────────────────────────────────────────────────
|
||
|
||
|
||
def _handle_admin_holiday(respond, schedule, parts, client, schedule_channel):
|
||
"""Dispatch ``admin holiday add|remove|list``.
|
||
|
||
``parts`` is the whitespace-split command, where ``parts[1] == "holiday"``.
|
||
"""
|
||
if len(parts) < 3:
|
||
respond(text="Usage: `admin holiday add|remove|list ...`")
|
||
return
|
||
holiday_cmd = parts[2]
|
||
|
||
if holiday_cmd == "add":
|
||
_admin_holiday_add(respond, schedule, parts, client, schedule_channel)
|
||
elif holiday_cmd == "remove":
|
||
_admin_holiday_remove(respond, schedule, parts, client, schedule_channel)
|
||
elif holiday_cmd == "list":
|
||
_admin_holiday_list(respond, schedule)
|
||
else:
|
||
respond(text="Unknown holiday command. Use `add`, `remove`, or `list`.")
|
||
|
||
|
||
def _admin_holiday_add(respond, schedule, parts, client, schedule_channel):
|
||
"""`admin holiday add <date> <slots> [x<mult>] <label>`.
|
||
|
||
Creates the HOLIDAY record, provisions the 08:00 activate / 17:00 deactivate
|
||
one-off schedules (storing their names on the record), inline-activates when
|
||
the window is already open, and announces the holiday in the channel.
|
||
"""
|
||
if len(parts) < 6:
|
||
respond(
|
||
text=(
|
||
"Usage: `/oncall admin holiday add <date> <slots> [x<mult>] <label>`\n"
|
||
"e.g. `/oncall admin holiday add 2026-07-04 2 x2 Independence Day`"
|
||
)
|
||
)
|
||
return
|
||
|
||
date = parse_date(parts[3])
|
||
if not date:
|
||
respond(text=f"Couldn't parse date: `{parts[3]}`")
|
||
return
|
||
date_str = date.strftime("%Y-%m-%d")
|
||
if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"):
|
||
respond(text="You can't schedule a holiday in the past.")
|
||
return
|
||
|
||
try:
|
||
slots = int(parts[4])
|
||
except ValueError:
|
||
respond(text=f"Slots must be a whole number, got `{parts[4]}`.")
|
||
return
|
||
if slots < 1:
|
||
respond(text="Slots must be at least 1.")
|
||
return
|
||
|
||
# Optional ``x<mult>`` token before the label.
|
||
rest = parts[5:]
|
||
multiplier = None
|
||
if rest and re.fullmatch(r"x[0-9]+(\.[0-9]+)?", rest[0], re.IGNORECASE):
|
||
multiplier = rest[0][1:]
|
||
rest = rest[1:]
|
||
label = " ".join(rest).strip()
|
||
if not label:
|
||
respond(text="A holiday label is required.")
|
||
return
|
||
|
||
schedule_names = _create_holiday_schedules(date_str)
|
||
created = schedule.create_holiday(
|
||
date_str,
|
||
slots=slots,
|
||
label=label,
|
||
created_by=schedule_channel or "",
|
||
multiplier=multiplier,
|
||
schedule_names=schedule_names,
|
||
)
|
||
if not created:
|
||
# Roll back the schedules we just made for a date that already has one.
|
||
_delete_holiday_schedules(schedule_names)
|
||
respond(
|
||
text=f"A holiday already exists on *{date.strftime('%A, %b %-d')}*. "
|
||
"Remove it first to recreate."
|
||
)
|
||
return
|
||
|
||
# If the window is already open (admin added it mid-day), the 08:00 schedule
|
||
# has passed, so repoint the call flow now via the holiday router.
|
||
if _holiday_window_active(date_str):
|
||
_activate_holiday_inline(schedule, date_str)
|
||
|
||
holiday = schedule.get_holiday(date_str)
|
||
multiplier_value = holiday["multiplier"] if holiday else (multiplier or "1.5")
|
||
respond(
|
||
text=(
|
||
f"Scheduled *{label}* holiday on *{date.strftime('%A, %b %-d')}* — "
|
||
f"{slots} slot{'s' if slots != 1 else ''} at {float(multiplier_value):g}x pay."
|
||
)
|
||
)
|
||
|
||
if schedule_channel:
|
||
try:
|
||
client.chat_postMessage(
|
||
channel=schedule_channel,
|
||
blocks=build_holiday_added_blocks(
|
||
date_str, label, slots, multiplier_value
|
||
),
|
||
text=f"Holiday added: {label} on {date_str}",
|
||
)
|
||
except Exception:
|
||
logger.exception("Failed to post holiday-added notification to channel")
|
||
_refresh_schedule_post(schedule, schedule_channel, client)
|
||
|
||
|
||
def _admin_holiday_remove(respond, schedule, parts, client, schedule_channel):
|
||
"""`admin holiday remove <date>` — delete the record + outstanding schedules."""
|
||
if len(parts) < 4:
|
||
respond(text="Usage: `/oncall admin holiday remove <date>`")
|
||
return
|
||
date = parse_date(parts[3])
|
||
if not date:
|
||
respond(text=f"Couldn't parse date: `{parts[3]}`")
|
||
return
|
||
date_str = date.strftime("%Y-%m-%d")
|
||
|
||
holiday = schedule.get_holiday(date_str)
|
||
if not holiday:
|
||
respond(text=f"No holiday scheduled on *{date.strftime('%A, %b %-d')}*.")
|
||
return
|
||
|
||
_delete_holiday_schedules(holiday.get("schedule_names", []))
|
||
schedule.remove_holiday(date_str)
|
||
respond(
|
||
text=(
|
||
f"Removed the *{holiday.get('label', 'holiday')}* holiday on "
|
||
f"*{date.strftime('%A, %b %-d')}*."
|
||
)
|
||
)
|
||
_refresh_schedule_post(schedule, schedule_channel, client)
|
||
|
||
|
||
def _admin_holiday_list(respond, schedule):
|
||
"""`admin holiday list` — show today-and-future scheduled holidays."""
|
||
today_str = datetime.now(EASTERN).strftime("%Y-%m-%d")
|
||
resp = schedule.table.query(
|
||
KeyConditionExpression=Key("PK").eq("HOLIDAY") & Key("SK").gte(today_str)
|
||
)
|
||
items = resp.get("Items", [])
|
||
if not items:
|
||
respond(text="No upcoming holidays scheduled.")
|
||
return
|
||
|
||
lines = ["*Upcoming Holidays*\n"]
|
||
for item in sorted(items, key=lambda x: x["SK"]):
|
||
date_str = item["SK"]
|
||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||
slots = int(item.get("slots", 0))
|
||
filled = len(item.get("assignees", {}) or {})
|
||
mult = f"{float(item.get('multiplier', 1.5)):g}x"
|
||
lines.append(
|
||
f"• {dt.strftime('%a %b %-d')} — _{item.get('label', 'Holiday')}_ "
|
||
f"({filled}/{slots} filled, {mult})"
|
||
)
|
||
respond(text="\n".join(lines))
|
||
|
||
|
||
# ── App Home ────────────────────────────────────────────────────────────
|
||
|
||
|
||
@functools.lru_cache(maxsize=1)
|
||
def _changelog_text() -> str:
|
||
"""Read the CHANGELOG shipped in this function's package.
|
||
|
||
Lazy (never at import) and tolerant of a missing file, so the App Home tab
|
||
degrades to "no What's New section" rather than erroring. The copy lives at
|
||
``$LAMBDA_TASK_ROOT/CHANGELOG.md`` (synced from the repo root).
|
||
"""
|
||
path = os.path.join(os.environ.get("LAMBDA_TASK_ROOT", "."), "CHANGELOG.md")
|
||
try:
|
||
with open(path, encoding="utf-8") as fh:
|
||
return fh.read()
|
||
except OSError:
|
||
logger.warning("CHANGELOG.md not found at %s — App Home omits What's New", path)
|
||
return ""
|
||
|
||
|
||
def publish_home(client, user_id: str, changelog_text: str) -> None:
|
||
"""Render and publish the App Home view for ``user_id``."""
|
||
entry = latest_entry(changelog_text)
|
||
view = build_home_view(
|
||
version=entry.version if entry else None,
|
||
notes=entry.body if entry else "",
|
||
date_label=entry.date_label if entry else "",
|
||
)
|
||
client.views_publish(user_id=user_id, view=view)
|
||
|
||
|
||
# ── App factory ─────────────────────────────────────────────────────────
|
||
|
||
|
||
def create_app(
|
||
bot_token: str, signing_secret: str, schedule_channel: str | None = None
|
||
) -> App:
|
||
app = App(
|
||
token=bot_token,
|
||
signing_secret=signing_secret,
|
||
process_before_response=True,
|
||
)
|
||
schedule = ShiftSchedule()
|
||
|
||
@app.command("/oncall")
|
||
def handle_oncall(ack, command, respond, client):
|
||
ack()
|
||
dispatch_oncall(command, respond, client, schedule, schedule_channel)
|
||
|
||
# Open-shift pickup buttons (``pickup_<date>[_day]``). The negative
|
||
# lookahead keeps this from also matching the late-pickup approval buttons
|
||
# (``pickup_approve_…`` / ``pickup_deny_…``), which have their own handlers.
|
||
@app.action(re.compile(r"^pickup_(?!approve_|deny_)"))
|
||
def handle_pickup_button(ack, body, client, respond):
|
||
ack()
|
||
handle_pickup(body, respond, client, schedule, schedule_channel)
|
||
|
||
@app.action(re.compile(r"^pickup_approve_"))
|
||
def handle_pickup_approve_button(ack, body, client, respond):
|
||
ack()
|
||
handle_pickup_approve(body, respond, client, schedule, schedule_channel)
|
||
|
||
@app.action(re.compile(r"^pickup_deny_"))
|
||
def handle_pickup_deny_button(ack, body, client, respond):
|
||
ack()
|
||
handle_pickup_deny(body, respond, client, schedule, schedule_channel)
|
||
|
||
@app.action(re.compile(r"^swap_accept_"))
|
||
def handle_swap_accept_button(ack, body, client, respond):
|
||
ack()
|
||
handle_swap_accept(body, respond, client, schedule, schedule_channel)
|
||
|
||
@app.action(re.compile(r"^swap_decline_"))
|
||
def handle_swap_decline_button(ack, body, client, respond):
|
||
ack()
|
||
handle_swap_decline(body, respond, client, schedule, schedule_channel)
|
||
|
||
@app.event("app_home_opened")
|
||
def handle_app_home_opened(event, client):
|
||
# Fires for the Messages tab too; only (re)publish the Home tab.
|
||
if event.get("tab") != "home":
|
||
return
|
||
publish_home(client, event["user"], _changelog_text())
|
||
|
||
return app
|