diff --git a/src/shared/python/shared/blocks.py b/src/shared/python/shared/blocks.py index 7d35abe..b362057 100644 --- a/src/shared/python/shared/blocks.py +++ b/src/shared/python/shared/blocks.py @@ -144,7 +144,7 @@ def build_shift_change_message( return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}] -def build_help_blocks() -> list[dict]: +def build_help_blocks(is_admin: bool = False) -> list[dict]: """Build help message blocks.""" text = ( "*After-Hours Shift Manager*\n\n" @@ -162,6 +162,16 @@ def build_help_blocks() -> list[dict]: "`/oncall help` — Show this help message\n\n" "_Dates can be: today, tomorrow, monday, friday, 4/5, 2026-04-05_" ) + if is_admin: + text += ( + "\n\n*Admin Commands*\n" + "`/oncall admin override ` — Assign a shift to an extension\n" + "`/oncall admin open ` — Mark a shift as open\n" + "`/oncall admin clear ` — Remove override (revert to weekly)\n" + "`/oncall admin roster add ` — Add a roster entry\n" + "`/oncall admin roster remove ` — Remove a roster entry\n" + "`/oncall admin roster rename ` — Rename an employee" + ) return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}] diff --git a/src/shared/python/shared/schedule.py b/src/shared/python/shared/schedule.py index 5a29d31..2644d50 100644 --- a/src/shared/python/shared/schedule.py +++ b/src/shared/python/shared/schedule.py @@ -179,6 +179,32 @@ class ShiftSchedule: resp = self.table.get_item(Key={"PK": "CONFIG", "SK": "CONFIG"}) return resp.get("Item", {}) + def get_admin_users(self) -> list[str]: + config = self.get_config() + return config.get("admin_users", []) + + def add_roster_entry(self, extension: str, name: str) -> None: + self.table.put_item( + Item={ + "PK": "ROSTER", + "SK": extension, + "name": name, + "extension": extension, + "slack_user_id": "", + } + ) + + def remove_roster_entry(self, extension: str) -> None: + self.table.delete_item(Key={"PK": "ROSTER", "SK": extension}) + + def rename_roster_entry(self, extension: str, new_name: str) -> None: + self.table.update_item( + Key={"PK": "ROSTER", "SK": extension}, + UpdateExpression="SET #n = :name", + ExpressionAttributeNames={"#n": "name"}, + ExpressionAttributeValues={":name": new_name}, + ) + def set_default_shift_rate(self, rate: float) -> None: """Set the global default shift rate in config.""" self.table.update_item( diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index f254b54..8c1ffa7 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -107,12 +107,14 @@ def create_app( user_id = command["user_id"] channel_id = command["channel_id"] + is_admin = user_id in schedule.get_admin_users() + 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()) + respond(blocks=build_help_blocks(is_admin=is_admin)) elif text == "roster": respond(blocks=build_roster_blocks(schedule.get_roster())) elif text == "pay": @@ -133,6 +135,8 @@ def create_app( _handle_swap( respond, schedule, user_id, text, schedule_channel or channel_id, client ) + elif text.startswith("admin"): + _handle_admin(respond, schedule, user_id, text, is_admin, client) else: respond(text="Unknown command. Try `/oncall help`") @@ -511,4 +515,124 @@ def create_app( logger.exception("Failed to post swap notification to channel") _refresh_schedule_post(client) + def _handle_admin(respond, schedule, user_id, text, is_admin, client): + 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 ` — Assign shift\n" + "`admin open ` — Mark open\n" + "`admin clear ` — 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 `") + return + date = parse_date(parts[2]) + if not date: + respond(text=f"Couldn't parse date: `{parts[2]}`") + 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 + date_str = date.strftime("%Y-%m-%d") + schedule.set_override(date_str, employee["extension"], employee["name"]) + respond( + text=f"Override set: *{date.strftime('%A, %b %-d')}* → {employee['name']} (Ext {ext})" + ) + _refresh_schedule_post(client) + + elif subcmd == "open": + if len(parts) < 3: + respond(text="Usage: `/oncall admin open `") + return + date = parse_date(parts[2]) + if not date: + respond(text=f"Couldn't parse date: `{parts[2]}`") + return + date_str = date.strftime("%Y-%m-%d") + schedule.mark_open(date_str) + respond(text=f"*{date.strftime('%A, %b %-d')}* marked as open.") + _refresh_schedule_post(client) + + elif subcmd == "clear": + if len(parts) < 3: + respond(text="Usage: `/oncall admin clear `") + return + date = parse_date(parts[2]) + if not date: + respond(text=f"Couldn't parse date: `{parts[2]}`") + return + date_str = date.strftime("%Y-%m-%d") + schedule.remove_override(date_str) + respond( + text=f"Override cleared for *{date.strftime('%A, %b %-d')}* — reverted to weekly schedule." + ) + _refresh_schedule_post(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:]) + schedule.add_roster_entry(ext, name) + 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`.") + return app