mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-02 01:33:11 +00:00
Switch from RingGroup API to Queue API at extension 801
The 3CX routing was changed from ring group 800 to queue 801 in a previous PR on ring-scheduler-3cx. Updates all callers and the SAM template parameter default accordingly.
This commit is contained in:
parent
1f2237f442
commit
954ac018ce
6 changed files with 84 additions and 44 deletions
10
README.md
10
README.md
|
|
@ -1,10 +1,10 @@
|
|||
# After-Hours Shift Manager
|
||||
|
||||
Slack bot for managing after-hours on-call shifts at Sea Haven Industries. Employees can pick up, drop, and swap shifts directly from Slack. Changes automatically update 3CX ring group routing via the integrated ring scheduler.
|
||||
Slack bot for managing after-hours on-call shifts at Sea Haven Industries. Employees can pick up, drop, and swap shifts directly from Slack. Changes automatically update 3CX queue routing via the integrated ring scheduler.
|
||||
|
||||
## How It Works
|
||||
|
||||
A recurring weekly schedule assigns employees to after-hours phone duty. Weekend shifts are split into Day (8am-5pm) and Night (5pm-8am). Any unassigned shift shows as **Available** in Slack with a pickup button. When someone picks up or drops a shift for today, the 3CX ring group is updated immediately. Future changes take effect when the ring scheduler runs at 8am daily and 5pm on weekends.
|
||||
A recurring weekly schedule assigns employees to after-hours phone duty. Weekend shifts are split into Day (8am-5pm) and Night (5pm-8am). Any unassigned shift shows as **Available** in Slack with a pickup button. When someone picks up or drops a shift for today, the 3CX queue is updated immediately. Future changes take effect when the ring scheduler runs at 8am daily and 5pm on weekends.
|
||||
|
||||
The weekly schedule post is updated live when shifts change, and the previous week's post is automatically deleted when the new one goes out.
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ Dates accept: `today`, `tomorrow`, `monday`-`sunday`, `4/5`, `2026-04-05`
|
|||
- **Data**: DynamoDB single-table (`afterhours-shifts`)
|
||||
- **IaC**: AWS SAM (`template.yaml`) with shared Lambda Layer
|
||||
- **Slack**: Slack Bolt framework with `/oncall` slash command
|
||||
- **3CX Integration**: Ring group routing updated directly via 3CX RingGroup XAPI
|
||||
- **3CX Integration**: Queue routing updated directly via 3CX Queue XAPI
|
||||
- **Secrets**: AWS Secrets Manager (`afterhours-shift-manager/*`)
|
||||
|
||||
### Lambda Functions
|
||||
|
|
@ -56,7 +56,7 @@ Dates accept: `today`, `tomorrow`, `monday`-`sunday`, `4/5`, `2026-04-05`
|
|||
| `afterhours-shift-manager` | API Gateway (POST /slack/events) | Slack bot — handles `/oncall` commands and interactive buttons |
|
||||
| `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts weekly schedule to Slack, sends pay report email |
|
||||
| `afterhours-roster-sync` | EventBridge (daily 6am ET) | Syncs employee roster from 3CX |
|
||||
| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX ring group routing based on who's on shift |
|
||||
| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX queue routing based on who's on shift |
|
||||
|
||||
### Project Layout
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ src/
|
|||
slack-bot/ Slack Bolt Lambda (handler + app)
|
||||
weekly-post/ Monday schedule + pay post
|
||||
roster-sync/ Daily 3CX roster sync
|
||||
ring-scheduler/ 3CX ring group routing updates
|
||||
ring-scheduler/ 3CX queue routing updates
|
||||
shared/ Lambda Layer (schedule, blocks, 3CX client, secrets)
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Lambda handler — updates 3CX ring group routing based on who's on shift.
|
||||
"""Lambda handler — updates 3CX queue routing based on who's on shift.
|
||||
|
||||
Triggered by EventBridge at 8am ET daily and 5pm ET on weekends.
|
||||
Also invoked directly by the Slack bot for same-day shift changes.
|
||||
|
|
@ -10,7 +10,7 @@ import os
|
|||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from shared.ring_scheduler import update_ring_group
|
||||
from shared.ring_scheduler import update_queue_routing
|
||||
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
||||
from shared.secrets import get_secret
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ def handler(event, context):
|
|||
now = datetime.now(EASTERN)
|
||||
current_hour = now.hour
|
||||
day_name = now.strftime("%A")
|
||||
logger.info("Running ring group scheduler at %s", now.isoformat())
|
||||
logger.info("Running queue scheduler at %s", now.isoformat())
|
||||
|
||||
force = event.get("force", False)
|
||||
valid_hours = {8, 17} if day_name in WEEKEND_DAYS else {8}
|
||||
|
|
@ -55,9 +55,9 @@ def handler(event, context):
|
|||
extension = ext if source != "available" else FALLBACK_EXTENSION
|
||||
|
||||
secret_prefix = os.environ["TCX_SECRET_PREFIX"]
|
||||
result = update_ring_group(
|
||||
result = update_queue_routing(
|
||||
extension=extension,
|
||||
ring_group_number=os.environ["RING_GROUP_NUMBER"],
|
||||
queue_number=os.environ["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"),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Core ring group routing logic shared between the scheduled Lambda and the Slack bot."""
|
||||
"""Core queue routing logic shared between the scheduled Lambda and the Slack bot."""
|
||||
|
||||
import logging
|
||||
|
||||
|
|
@ -7,27 +7,27 @@ from shared.three_cx_client import ThreeCXClient
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def update_ring_group(
|
||||
def update_queue_routing(
|
||||
extension: str,
|
||||
ring_group_number: str,
|
||||
queue_number: str,
|
||||
domain: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
) -> dict:
|
||||
"""Update 3CX ring group forwarding to route calls to the given extension."""
|
||||
"""Update 3CX queue forwarding to route calls to the given extension."""
|
||||
client = ThreeCXClient(
|
||||
domain=domain,
|
||||
auth_mode="oauth",
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
ring_group = client.get_ring_group(ring_group_number)
|
||||
client.update_ring_group_forwarding(
|
||||
ring_group_id=ring_group["Id"],
|
||||
queue = client.get_queue(queue_number)
|
||||
client.update_queue_forwarding(
|
||||
queue_id=queue["Id"],
|
||||
closed_destination=extension,
|
||||
holiday_destination=extension,
|
||||
)
|
||||
logger.info(
|
||||
"Updated ring group %s to forward to Ext %s", ring_group_number, extension
|
||||
"Updated queue %s to forward to Ext %s", queue_number, extension
|
||||
)
|
||||
return {"extension": extension, "ring_group": ring_group_number}
|
||||
return {"extension": extension, "queue": queue_number}
|
||||
|
|
|
|||
|
|
@ -98,13 +98,7 @@ class ThreeCXClient:
|
|||
closed_destination: str,
|
||||
holiday_destination: str,
|
||||
):
|
||||
"""Update the OutOfOfficeRoute and HolidaysRoute on a ring group.
|
||||
|
||||
Args:
|
||||
ring_group_id: Numeric ID from the ring group entity
|
||||
closed_destination: Extension number for after-hours routing
|
||||
holiday_destination: Extension number for holiday routing
|
||||
"""
|
||||
"""Update the OutOfOfficeRoute and HolidaysRoute on a ring group."""
|
||||
payload = {
|
||||
"OutOfOfficeRoute": {
|
||||
"IsPromptEnabled": False,
|
||||
|
|
@ -135,3 +129,49 @@ class ThreeCXClient:
|
|||
holiday_destination,
|
||||
)
|
||||
return resp.status_code
|
||||
|
||||
def get_queue(self, extension_number: str) -> dict:
|
||||
"""Fetch queue config by extension number."""
|
||||
resp = self.session.get(
|
||||
f"{self.base_url}/xapi/v1/Queues/Pbx.GetByNumber(number='{extension_number}')",
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def update_queue_forwarding(
|
||||
self,
|
||||
queue_id: int,
|
||||
closed_destination: str,
|
||||
holiday_destination: str,
|
||||
):
|
||||
"""Update the OutOfOfficeRoute and HolidaysRoute on a queue."""
|
||||
payload = {
|
||||
"OutOfOfficeRoute": {
|
||||
"IsPromptEnabled": False,
|
||||
"Route": {
|
||||
"To": "Extension",
|
||||
"Number": closed_destination,
|
||||
"External": "",
|
||||
},
|
||||
},
|
||||
"HolidaysRoute": {
|
||||
"IsPromptEnabled": False,
|
||||
"Route": {
|
||||
"To": "Extension",
|
||||
"Number": holiday_destination,
|
||||
"External": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
resp = self.session.patch(
|
||||
f"{self.base_url}/xapi/v1/Queues({queue_id})",
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
logger.info(
|
||||
"Updated queue %s: closed->Ext %s, holiday->Ext %s",
|
||||
queue_id,
|
||||
closed_destination,
|
||||
holiday_destination,
|
||||
)
|
||||
return resp.status_code
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from shared.blocks import (
|
|||
build_shift_change_message,
|
||||
build_week_schedule,
|
||||
)
|
||||
from shared.ring_scheduler import update_ring_group
|
||||
from shared.ring_scheduler import update_queue_routing
|
||||
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
||||
from shared.secrets import get_secret
|
||||
|
||||
|
|
@ -66,22 +66,22 @@ def parse_date(text: str) -> datetime | None:
|
|||
|
||||
|
||||
def _update_3cx_routing(extension: str) -> None:
|
||||
"""Update the 3CX ring group to forward calls to the given extension."""
|
||||
ring_group_number = os.environ.get("RING_GROUP_NUMBER")
|
||||
"""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 ring_group_number or not secret_prefix:
|
||||
logger.warning("3CX env vars not set — skipping ring group update")
|
||||
if not queue_number or not secret_prefix:
|
||||
logger.warning("3CX env vars not set — skipping queue update")
|
||||
return
|
||||
try:
|
||||
update_ring_group(
|
||||
update_queue_routing(
|
||||
extension=extension,
|
||||
ring_group_number=ring_group_number,
|
||||
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 ring group")
|
||||
logger.exception("Failed to update 3CX queue")
|
||||
|
||||
|
||||
def is_today(date_str: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ Parameters:
|
|||
ShiftChannel:
|
||||
Type: String
|
||||
Description: Slack channel ID for schedule posts and shift notifications
|
||||
RingGroupNumber:
|
||||
QueueNumber:
|
||||
Type: String
|
||||
Default: "800"
|
||||
Description: 3CX ring group extension number to update
|
||||
Default: "801"
|
||||
Description: 3CX queue extension number to update
|
||||
|
||||
Globals:
|
||||
Function:
|
||||
|
|
@ -70,7 +70,7 @@ Resources:
|
|||
SLACK_SIGNING_SECRET: afterhours-shift-manager/slack-signing-secret
|
||||
SHIFT_CHANNEL: !Ref ShiftChannel
|
||||
TCX_SECRET_PREFIX: afterhours-shift-manager/3cx-
|
||||
RING_GROUP_NUMBER: !Ref RingGroupNumber
|
||||
QUEUE_NUMBER: !Ref QueueNumber
|
||||
TZ: !Ref Timezone
|
||||
Policies:
|
||||
- DynamoDBCrudPolicy:
|
||||
|
|
@ -178,7 +178,7 @@ Resources:
|
|||
Description: "Sync roster from 3CX at 6am EDT"
|
||||
Enabled: true
|
||||
|
||||
# --- Ring Group Scheduler (daily 3CX routing updates) ---
|
||||
# --- Ring Scheduler (daily 3CX queue routing updates) ---
|
||||
RingSchedulerFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
|
|
@ -192,7 +192,7 @@ Resources:
|
|||
Variables:
|
||||
SHIFT_TABLE: !Ref ShiftTable
|
||||
TCX_SECRET_PREFIX: afterhours-shift-manager/3cx-
|
||||
RING_GROUP_NUMBER: !Ref RingGroupNumber
|
||||
QUEUE_NUMBER: !Ref QueueNumber
|
||||
TZ: !Ref Timezone
|
||||
Policies:
|
||||
- Statement:
|
||||
|
|
@ -212,26 +212,26 @@ Resources:
|
|||
Type: Schedule
|
||||
Properties:
|
||||
Schedule: cron(0 13 ? * * *)
|
||||
Description: "Update 3CX ring group at 8am EST"
|
||||
Description: "Update 3CX queue at 8am EST"
|
||||
Enabled: true
|
||||
DailyScheduleEDT:
|
||||
Type: Schedule
|
||||
Properties:
|
||||
Schedule: cron(0 12 ? * * *)
|
||||
Description: "Update 3CX ring group at 8am EDT"
|
||||
Description: "Update 3CX queue at 8am EDT"
|
||||
Enabled: true
|
||||
# Weekends at 5pm ET — switch to night shift person
|
||||
WeekendEveningEST:
|
||||
Type: Schedule
|
||||
Properties:
|
||||
Schedule: cron(0 22 ? * SAT,SUN *)
|
||||
Description: "Update 3CX ring group at 5pm EST weekends"
|
||||
Description: "Update 3CX queue at 5pm EST weekends"
|
||||
Enabled: true
|
||||
WeekendEveningEDT:
|
||||
Type: Schedule
|
||||
Properties:
|
||||
Schedule: cron(0 21 ? * SAT,SUN *)
|
||||
Description: "Update 3CX ring group at 5pm EDT weekends"
|
||||
Description: "Update 3CX queue at 5pm EDT weekends"
|
||||
Enabled: true
|
||||
|
||||
# --- CloudWatch Log Groups (explicit 60-day retention) ---
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue