"""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 logging import os import re from datetime import datetime, timedelta from zoneinfo import ZoneInfo from slack_bolt import App from shared.blocks import ( build_help_blocks, build_home_view, build_pay_summary_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, ShiftSchedule, determine_shift_type, ) from shared.secrets import get_secret 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 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 _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) 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.""" action_id = body["actions"][0]["action_id"] remainder = action_id.replace("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 ` first.", ) return claimed = schedule.claim_open_shift( date_str, employee["extension"], employee["name"], shift_type ) if not claimed: client.chat_postEphemeral( channel=channel_id, user=user_id, text=f"That shift on *{date_str}* was already picked up by someone else.", ) return if 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) # ── 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): 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 if parts[1] == "default": if len(parts) < 3: respond( text="Usage: `/oncall rate default ` (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 if len(parts) < 3: respond( text="Usage: `/oncall rate ` (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 ` (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, schedule_channel ): parts = text.split() if len(parts) < 2: respond( text="Usage: `/oncall pick [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 ` 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") if explicit_shift: shift_type = explicit_shift ext, name, source = schedule.resolve_shift(date_str, day_name, shift_type) elif day_name in WEEKEND_DAYS: shift_type = "night" for st in ("day", "night"): ext, name, source = schedule.resolve_shift(date_str, day_name, st) if source == "available": shift_type = st break else: ext, name, source = schedule.resolve_shift(date_str, day_name) else: shift_type = "night" 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"], shift_type) if is_today(date_str) and _is_active_shift_type(shift_type): _update_3cx_routing(employee["extension"]) date_label = date.strftime("%A, %b %-d") shift_label = _shift_type_label(day_name, shift_type) respond(text=f"You picked up the shift for *{date_label}*{shift_label}.") blocks = build_shift_change_message( user_id, date_str, "picked_up", employee["extension"], 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 _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 ` (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") 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") 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 @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 _handle_swap( respond, schedule, user_id, text, channel_id, client, schedule_channel ): # 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") 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") 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 `." ) 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 ` 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 schedule.set_override(date_str, swap["target_ext"], swap["target_name"], shift_type) 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") 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 [day|night]` — Assign shift\n" "`admin open [day|night]` — Mark open\n" "`admin clear [day|night]` — Remove override\n" "`admin roster add ` — Add employee\n" "`admin roster remove ` — Remove employee\n" "`admin roster rename ` — Rename" ) ) return subcmd = parts[1] if subcmd == "override": if len(parts) < 4: respond( text="Usage: `/oncall admin override [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 [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 [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 [name]`") return roster_cmd = parts[2] if roster_cmd == "add": if len(parts) < 5: respond(text="Usage: `/oncall admin roster add `") 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 `") 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 `") 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`.") else: respond(text=f"Unknown admin command: `{subcmd}`. Try `/oncall help`.") # ── 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) @app.action(re.compile(r"^pickup_")) def handle_pickup_button(ack, body, client, respond): ack() handle_pickup(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