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:
Adam Moussa 2026-05-12 16:23:24 -04:00
parent 1f2237f442
commit 954ac018ce
6 changed files with 84 additions and 44 deletions

View file

@ -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)
```

View file

@ -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"),

View file

@ -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}

View file

@ -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

View file

@ -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:

View file

@ -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) ---