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 # 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 ## 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. 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`) - **Data**: DynamoDB single-table (`afterhours-shifts`)
- **IaC**: AWS SAM (`template.yaml`) with shared Lambda Layer - **IaC**: AWS SAM (`template.yaml`) with shared Lambda Layer
- **Slack**: Slack Bolt framework with `/oncall` slash command - **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/*`) - **Secrets**: AWS Secrets Manager (`afterhours-shift-manager/*`)
### Lambda Functions ### 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-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-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-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 ### Project Layout
@ -65,7 +65,7 @@ src/
slack-bot/ Slack Bolt Lambda (handler + app) slack-bot/ Slack Bolt Lambda (handler + app)
weekly-post/ Monday schedule + pay post weekly-post/ Monday schedule + pay post
roster-sync/ Daily 3CX roster sync 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) 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. Triggered by EventBridge at 8am ET daily and 5pm ET on weekends.
Also invoked directly by the Slack bot for same-day shift changes. Also invoked directly by the Slack bot for same-day shift changes.
@ -10,7 +10,7 @@ import os
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo 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.schedule import FALLBACK_EXTENSION, ShiftSchedule
from shared.secrets import get_secret from shared.secrets import get_secret
@ -32,7 +32,7 @@ def handler(event, context):
now = datetime.now(EASTERN) now = datetime.now(EASTERN)
current_hour = now.hour current_hour = now.hour
day_name = now.strftime("%A") 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) force = event.get("force", False)
valid_hours = {8, 17} if day_name in WEEKEND_DAYS else {8} 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 extension = ext if source != "available" else FALLBACK_EXTENSION
secret_prefix = os.environ["TCX_SECRET_PREFIX"] secret_prefix = os.environ["TCX_SECRET_PREFIX"]
result = update_ring_group( result = update_queue_routing(
extension=extension, extension=extension,
ring_group_number=os.environ["RING_GROUP_NUMBER"], queue_number=os.environ["QUEUE_NUMBER"],
domain=get_secret(f"{secret_prefix}domain"), domain=get_secret(f"{secret_prefix}domain"),
client_id=get_secret(f"{secret_prefix}client-id"), client_id=get_secret(f"{secret_prefix}client-id"),
client_secret=get_secret(f"{secret_prefix}client-secret"), 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 import logging
@ -7,27 +7,27 @@ from shared.three_cx_client import ThreeCXClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def update_ring_group( def update_queue_routing(
extension: str, extension: str,
ring_group_number: str, queue_number: str,
domain: str, domain: str,
client_id: str, client_id: str,
client_secret: str, client_secret: str,
) -> dict: ) -> 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( client = ThreeCXClient(
domain=domain, domain=domain,
auth_mode="oauth", auth_mode="oauth",
client_id=client_id, client_id=client_id,
client_secret=client_secret, client_secret=client_secret,
) )
ring_group = client.get_ring_group(ring_group_number) queue = client.get_queue(queue_number)
client.update_ring_group_forwarding( client.update_queue_forwarding(
ring_group_id=ring_group["Id"], queue_id=queue["Id"],
closed_destination=extension, closed_destination=extension,
holiday_destination=extension, holiday_destination=extension,
) )
logger.info( 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, closed_destination: str,
holiday_destination: str, holiday_destination: str,
): ):
"""Update the OutOfOfficeRoute and HolidaysRoute on a ring group. """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
"""
payload = { payload = {
"OutOfOfficeRoute": { "OutOfOfficeRoute": {
"IsPromptEnabled": False, "IsPromptEnabled": False,
@ -135,3 +129,49 @@ class ThreeCXClient:
holiday_destination, holiday_destination,
) )
return resp.status_code 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_shift_change_message,
build_week_schedule, 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.schedule import FALLBACK_EXTENSION, ShiftSchedule
from shared.secrets import get_secret from shared.secrets import get_secret
@ -66,22 +66,22 @@ def parse_date(text: str) -> datetime | None:
def _update_3cx_routing(extension: str) -> None: def _update_3cx_routing(extension: str) -> None:
"""Update the 3CX ring group to forward calls to the given extension.""" """Update the 3CX queue to forward calls to the given extension."""
ring_group_number = os.environ.get("RING_GROUP_NUMBER") queue_number = os.environ.get("QUEUE_NUMBER")
secret_prefix = os.environ.get("TCX_SECRET_PREFIX") secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
if not ring_group_number or not secret_prefix: if not queue_number or not secret_prefix:
logger.warning("3CX env vars not set — skipping ring group update") logger.warning("3CX env vars not set — skipping queue update")
return return
try: try:
update_ring_group( update_queue_routing(
extension=extension, extension=extension,
ring_group_number=ring_group_number, queue_number=queue_number,
domain=get_secret(f"{secret_prefix}domain"), domain=get_secret(f"{secret_prefix}domain"),
client_id=get_secret(f"{secret_prefix}client-id"), client_id=get_secret(f"{secret_prefix}client-id"),
client_secret=get_secret(f"{secret_prefix}client-secret"), client_secret=get_secret(f"{secret_prefix}client-secret"),
) )
except Exception: except Exception:
logger.exception("Failed to update 3CX ring group") logger.exception("Failed to update 3CX queue")
def is_today(date_str: str) -> bool: def is_today(date_str: str) -> bool:

View file

@ -9,10 +9,10 @@ Parameters:
ShiftChannel: ShiftChannel:
Type: String Type: String
Description: Slack channel ID for schedule posts and shift notifications Description: Slack channel ID for schedule posts and shift notifications
RingGroupNumber: QueueNumber:
Type: String Type: String
Default: "800" Default: "801"
Description: 3CX ring group extension number to update Description: 3CX queue extension number to update
Globals: Globals:
Function: Function:
@ -70,7 +70,7 @@ Resources:
SLACK_SIGNING_SECRET: afterhours-shift-manager/slack-signing-secret SLACK_SIGNING_SECRET: afterhours-shift-manager/slack-signing-secret
SHIFT_CHANNEL: !Ref ShiftChannel SHIFT_CHANNEL: !Ref ShiftChannel
TCX_SECRET_PREFIX: afterhours-shift-manager/3cx- TCX_SECRET_PREFIX: afterhours-shift-manager/3cx-
RING_GROUP_NUMBER: !Ref RingGroupNumber QUEUE_NUMBER: !Ref QueueNumber
TZ: !Ref Timezone TZ: !Ref Timezone
Policies: Policies:
- DynamoDBCrudPolicy: - DynamoDBCrudPolicy:
@ -178,7 +178,7 @@ Resources:
Description: "Sync roster from 3CX at 6am EDT" Description: "Sync roster from 3CX at 6am EDT"
Enabled: true Enabled: true
# --- Ring Group Scheduler (daily 3CX routing updates) --- # --- Ring Scheduler (daily 3CX queue routing updates) ---
RingSchedulerFunction: RingSchedulerFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
@ -192,7 +192,7 @@ Resources:
Variables: Variables:
SHIFT_TABLE: !Ref ShiftTable SHIFT_TABLE: !Ref ShiftTable
TCX_SECRET_PREFIX: afterhours-shift-manager/3cx- TCX_SECRET_PREFIX: afterhours-shift-manager/3cx-
RING_GROUP_NUMBER: !Ref RingGroupNumber QUEUE_NUMBER: !Ref QueueNumber
TZ: !Ref Timezone TZ: !Ref Timezone
Policies: Policies:
- Statement: - Statement:
@ -212,26 +212,26 @@ Resources:
Type: Schedule Type: Schedule
Properties: Properties:
Schedule: cron(0 13 ? * * *) Schedule: cron(0 13 ? * * *)
Description: "Update 3CX ring group at 8am EST" Description: "Update 3CX queue at 8am EST"
Enabled: true Enabled: true
DailyScheduleEDT: DailyScheduleEDT:
Type: Schedule Type: Schedule
Properties: Properties:
Schedule: cron(0 12 ? * * *) Schedule: cron(0 12 ? * * *)
Description: "Update 3CX ring group at 8am EDT" Description: "Update 3CX queue at 8am EDT"
Enabled: true Enabled: true
# Weekends at 5pm ET — switch to night shift person # Weekends at 5pm ET — switch to night shift person
WeekendEveningEST: WeekendEveningEST:
Type: Schedule Type: Schedule
Properties: Properties:
Schedule: cron(0 22 ? * SAT,SUN *) 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 Enabled: true
WeekendEveningEDT: WeekendEveningEDT:
Type: Schedule Type: Schedule
Properties: Properties:
Schedule: cron(0 21 ? * SAT,SUN *) 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 Enabled: true
# --- CloudWatch Log Groups (explicit 60-day retention) --- # --- CloudWatch Log Groups (explicit 60-day retention) ---