wxcli 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wxcli/__init__.py +4 -0
- wxcli/_playbook/.claude/agents/migration-advisor.md +239 -0
- wxcli/_playbook/.claude/agents/wxc-calling-builder.md +946 -0
- wxcli/_playbook/.claude/rules/cleanup.md +29 -0
- wxcli/_playbook/.claude/rules/cucm-migration.md +58 -0
- wxcli/_playbook/.claude/rules/org-health.md +14 -0
- wxcli/_playbook/.claude/settings.json +8 -0
- wxcli/_playbook/.claude/skills/audit-compliance/SKILL.md +515 -0
- wxcli/_playbook/.claude/skills/call-control/SKILL.md +747 -0
- wxcli/_playbook/.claude/skills/configure-features/SKILL.md +623 -0
- wxcli/_playbook/.claude/skills/configure-routing/SKILL.md +627 -0
- wxcli/_playbook/.claude/skills/contact-center/SKILL.md +1181 -0
- wxcli/_playbook/.claude/skills/cucm-migrate/SKILL.md +820 -0
- wxcli/_playbook/.claude/skills/customer-assist/SKILL.md +443 -0
- wxcli/_playbook/.claude/skills/device-platform/SKILL.md +407 -0
- wxcli/_playbook/.claude/skills/manage-call-settings/SKILL.md +757 -0
- wxcli/_playbook/.claude/skills/manage-devices/SKILL.md +790 -0
- wxcli/_playbook/.claude/skills/manage-identity/SKILL.md +638 -0
- wxcli/_playbook/.claude/skills/manage-licensing/SKILL.md +528 -0
- wxcli/_playbook/.claude/skills/manage-meetings/SKILL.md +708 -0
- wxcli/_playbook/.claude/skills/messaging-bots/SKILL.md +365 -0
- wxcli/_playbook/.claude/skills/messaging-spaces/SKILL.md +520 -0
- wxcli/_playbook/.claude/skills/org-health/SKILL.md +159 -0
- wxcli/_playbook/.claude/skills/provision-calling/SKILL.md +421 -0
- wxcli/_playbook/.claude/skills/query-live/SKILL.md +183 -0
- wxcli/_playbook/.claude/skills/query-live/domains/call-flow-trace.md +223 -0
- wxcli/_playbook/.claude/skills/query-live/domains/call-history.md +44 -0
- wxcli/_playbook/.claude/skills/query-live/domains/features.md +169 -0
- wxcli/_playbook/.claude/skills/query-live/domains/numbers.md +93 -0
- wxcli/_playbook/.claude/skills/query-live/domains/people-and-settings.md +146 -0
- wxcli/_playbook/.claude/skills/query-live/domains/routing.md +124 -0
- wxcli/_playbook/.claude/skills/reporting/SKILL.md +501 -0
- wxcli/_playbook/.claude/skills/reporting/references/cdr-recipes.md +1628 -0
- wxcli/_playbook/.claude/skills/reporting-cc/SKILL.md +251 -0
- wxcli/_playbook/.claude/skills/reporting-meetings/SKILL.md +290 -0
- wxcli/_playbook/.claude/skills/teardown/SKILL.md +214 -0
- wxcli/_playbook/.claude/skills/video-mesh/SKILL.md +379 -0
- wxcli/_playbook/.claude/skills/wxc-calling-debug/SKILL.md +475 -0
- wxcli/_playbook/CLAUDE.md +373 -0
- wxcli/_playbook/docs/reference/CLAUDE.md +25 -0
- wxcli/_playbook/docs/reference/admin-apps-data.md +643 -0
- wxcli/_playbook/docs/reference/admin-audit-security.md +322 -0
- wxcli/_playbook/docs/reference/admin-hybrid.md +383 -0
- wxcli/_playbook/docs/reference/admin-identity-scim.md +1007 -0
- wxcli/_playbook/docs/reference/admin-licensing.md +414 -0
- wxcli/_playbook/docs/reference/admin-org-management.md +586 -0
- wxcli/_playbook/docs/reference/admin-partner.md +357 -0
- wxcli/_playbook/docs/reference/archive/wxc-sdk-patterns.md +1229 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-advanced.md +835 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-core.md +743 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-devices-workspaces.md +1305 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-features.md +849 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-locations.md +938 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-person.md +1255 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-routing.md +1262 -0
- wxcli/_playbook/docs/reference/authentication.md +965 -0
- wxcli/_playbook/docs/reference/call-control.md +1205 -0
- wxcli/_playbook/docs/reference/call-features-additional.md +2541 -0
- wxcli/_playbook/docs/reference/call-features-major.md +1320 -0
- wxcli/_playbook/docs/reference/call-routing.md +2304 -0
- wxcli/_playbook/docs/reference/contact-center-analytics.md +1049 -0
- wxcli/_playbook/docs/reference/contact-center-core.md +1614 -0
- wxcli/_playbook/docs/reference/contact-center-journey.md +457 -0
- wxcli/_playbook/docs/reference/contact-center-routing.md +1138 -0
- wxcli/_playbook/docs/reference/devices-core.md +1869 -0
- wxcli/_playbook/docs/reference/devices-dect.md +1150 -0
- wxcli/_playbook/docs/reference/devices-platform.md +467 -0
- wxcli/_playbook/docs/reference/devices-workspaces.md +1426 -0
- wxcli/_playbook/docs/reference/emergency-services.md +1122 -0
- wxcli/_playbook/docs/reference/location-calling-core.md +2037 -0
- wxcli/_playbook/docs/reference/location-calling-media.md +1334 -0
- wxcli/_playbook/docs/reference/location-recording-advanced.md +1482 -0
- wxcli/_playbook/docs/reference/meetings-content.md +473 -0
- wxcli/_playbook/docs/reference/meetings-core.md +903 -0
- wxcli/_playbook/docs/reference/meetings-infrastructure.md +899 -0
- wxcli/_playbook/docs/reference/meetings-settings.md +850 -0
- wxcli/_playbook/docs/reference/messaging-bots.md +1091 -0
- wxcli/_playbook/docs/reference/messaging-spaces.md +695 -0
- wxcli/_playbook/docs/reference/person-call-settings-behavior.md +1639 -0
- wxcli/_playbook/docs/reference/person-call-settings-handling.md +1394 -0
- wxcli/_playbook/docs/reference/person-call-settings-media.md +1898 -0
- wxcli/_playbook/docs/reference/person-call-settings-permissions.md +1329 -0
- wxcli/_playbook/docs/reference/provisioning.md +1366 -0
- wxcli/_playbook/docs/reference/reporting-analytics.md +1318 -0
- wxcli/_playbook/docs/reference/self-service-call-settings.md +1020 -0
- wxcli/_playbook/docs/reference/virtual-lines.md +1132 -0
- wxcli/_playbook/docs/reference/webhooks-events.md +1444 -0
- wxcli/_playbook/docs/reference/wxcadm-xsi-realtime.md +953 -0
- wxcli/_version.py +24 -0
- wxcli/auth.py +137 -0
- wxcli/commands/__init__.py +0 -0
- wxcli/commands/_registry.py +181 -0
- wxcli/commands/activation_email.py +103 -0
- wxcli/commands/admin_recordings.py +585 -0
- wxcli/commands/analytics.py +105 -0
- wxcli/commands/announcement_playlists.py +219 -0
- wxcli/commands/announcements.py +519 -0
- wxcli/commands/archive_users.py +36 -0
- wxcli/commands/attachment_actions.py +60 -0
- wxcli/commands/audit_events.py +83 -0
- wxcli/commands/authorizations.py +113 -0
- wxcli/commands/auto_attendant.py +642 -0
- wxcli/commands/broadworks_billing_reports.py +126 -0
- wxcli/commands/broadworks_enterprises.py +160 -0
- wxcli/commands/broadworks_subscribers.py +319 -0
- wxcli/commands/broadworks_workspaces.py +108 -0
- wxcli/commands/call_controls.py +1304 -0
- wxcli/commands/call_park.py +494 -0
- wxcli/commands/call_pickup.py +229 -0
- wxcli/commands/call_queue.py +1878 -0
- wxcli/commands/call_recording.py +707 -0
- wxcli/commands/call_routing.py +1610 -0
- wxcli/commands/call_settings_for_me_phase_5.py +212 -0
- wxcli/commands/caller_reputation.py +169 -0
- wxcli/commands/calling_service.py +305 -0
- wxcli/commands/cc_address_book.py +770 -0
- wxcli/commands/cc_agent_greetings.py +242 -0
- wxcli/commands/cc_agent_summaries.py +39 -0
- wxcli/commands/cc_agent_wellbeing.py +216 -0
- wxcli/commands/cc_agents.py +425 -0
- wxcli/commands/cc_ai_assistant.py +55 -0
- wxcli/commands/cc_ai_feature.py +294 -0
- wxcli/commands/cc_audio_files.py +149 -0
- wxcli/commands/cc_auto_csat.py +311 -0
- wxcli/commands/cc_aux_code.py +458 -0
- wxcli/commands/cc_business_hour.py +353 -0
- wxcli/commands/cc_call_monitoring.py +228 -0
- wxcli/commands/cc_callbacks.py +194 -0
- wxcli/commands/cc_campaign.py +212 -0
- wxcli/commands/cc_captures.py +39 -0
- wxcli/commands/cc_contact_list.py +169 -0
- wxcli/commands/cc_contact_number.py +295 -0
- wxcli/commands/cc_data_sources.py +235 -0
- wxcli/commands/cc_desktop_layout.py +380 -0
- wxcli/commands/cc_desktop_profile.py +322 -0
- wxcli/commands/cc_dial_number.py +511 -0
- wxcli/commands/cc_dial_plan.py +347 -0
- wxcli/commands/cc_dnc.py +101 -0
- wxcli/commands/cc_entry_point.py +545 -0
- wxcli/commands/cc_ewt.py +51 -0
- wxcli/commands/cc_flow.py +477 -0
- wxcli/commands/cc_global_vars.py +430 -0
- wxcli/commands/cc_holiday_list.py +335 -0
- wxcli/commands/cc_journey.py +1261 -0
- wxcli/commands/cc_legacy_flows.py +49 -0
- wxcli/commands/cc_multimedia_profile.py +436 -0
- wxcli/commands/cc_notification.py +51 -0
- wxcli/commands/cc_outdial_ani.py +604 -0
- wxcli/commands/cc_overrides.py +341 -0
- wxcli/commands/cc_queue.py +1083 -0
- wxcli/commands/cc_queue_stats.py +55 -0
- wxcli/commands/cc_realtime.py +51 -0
- wxcli/commands/cc_resource_collection.py +312 -0
- wxcli/commands/cc_search.py +46 -0
- wxcli/commands/cc_site.py +400 -0
- wxcli/commands/cc_skill.py +350 -0
- wxcli/commands/cc_skill_profile.py +289 -0
- wxcli/commands/cc_subscriptions.py +426 -0
- wxcli/commands/cc_summaries.py +130 -0
- wxcli/commands/cc_tasks.py +844 -0
- wxcli/commands/cc_team.py +325 -0
- wxcli/commands/cc_user_profiles.py +349 -0
- wxcli/commands/cc_users.py +701 -0
- wxcli/commands/cc_work_types.py +363 -0
- wxcli/commands/cdr.py +89 -0
- wxcli/commands/classifications.py +38 -0
- wxcli/commands/cleanup.py +1427 -0
- wxcli/commands/client_settings.py +72 -0
- wxcli/commands/conference.py +328 -0
- wxcli/commands/configure.py +72 -0
- wxcli/commands/converged_recordings.py +347 -0
- wxcli/commands/converged_recordings_export.py +310 -0
- wxcli/commands/cq_playlists.py +42 -0
- wxcli/commands/cucm.py +2794 -0
- wxcli/commands/cucm_config.py +129 -0
- wxcli/commands/customer_assist.py +373 -0
- wxcli/commands/data_sources.py +227 -0
- wxcli/commands/dect_devices.py +787 -0
- wxcli/commands/device_configurations.py +77 -0
- wxcli/commands/device_dynamic_settings.py +320 -0
- wxcli/commands/device_settings.py +1489 -0
- wxcli/commands/devices.py +280 -0
- wxcli/commands/domains.py +142 -0
- wxcli/commands/ecm.py +178 -0
- wxcli/commands/emergency_services.py +827 -0
- wxcli/commands/events.py +83 -0
- wxcli/commands/external_voicemail.py +52 -0
- wxcli/commands/groups.py +206 -0
- wxcli/commands/guest_management.py +76 -0
- wxcli/commands/hds.py +340 -0
- wxcli/commands/hot_desk.py +67 -0
- wxcli/commands/hot_desking_members.py +118 -0
- wxcli/commands/hot_desking_portal.py +124 -0
- wxcli/commands/hunt_group.py +577 -0
- wxcli/commands/hybrid_clusters.py +71 -0
- wxcli/commands/hybrid_connectors.py +67 -0
- wxcli/commands/identity_org.py +159 -0
- wxcli/commands/init_playbook.py +155 -0
- wxcli/commands/licenses.py +106 -0
- wxcli/commands/live_monitoring.py +40 -0
- wxcli/commands/location_call_handling.py +577 -0
- wxcli/commands/location_schedules.py +341 -0
- wxcli/commands/location_settings.py +1297 -0
- wxcli/commands/location_voicemail.py +494 -0
- wxcli/commands/locations.py +336 -0
- wxcli/commands/meeting_captions.py +110 -0
- wxcli/commands/meeting_chats.py +63 -0
- wxcli/commands/meeting_invitees.py +234 -0
- wxcli/commands/meeting_messages.py +28 -0
- wxcli/commands/meeting_participants.py +278 -0
- wxcli/commands/meeting_polls.py +111 -0
- wxcli/commands/meeting_preferences.py +515 -0
- wxcli/commands/meeting_qa.py +78 -0
- wxcli/commands/meeting_qualities.py +43 -0
- wxcli/commands/meeting_reports.py +101 -0
- wxcli/commands/meeting_session_types.py +105 -0
- wxcli/commands/meeting_site.py +57 -0
- wxcli/commands/meeting_slido.py +44 -0
- wxcli/commands/meeting_summaries.py +92 -0
- wxcli/commands/meeting_tracking_codes.py +233 -0
- wxcli/commands/meeting_transcripts.py +230 -0
- wxcli/commands/meetings.py +1997 -0
- wxcli/commands/memberships.py +164 -0
- wxcli/commands/messages.py +213 -0
- wxcli/commands/mode_management.py +244 -0
- wxcli/commands/my_call_settings.py +3577 -0
- wxcli/commands/numbers.py +405 -0
- wxcli/commands/operating_modes.py +410 -0
- wxcli/commands/org_contacts.py +284 -0
- wxcli/commands/org_health_cli.py +55 -0
- wxcli/commands/org_settings.py +71 -0
- wxcli/commands/organizations.py +83 -0
- wxcli/commands/paging_group.py +257 -0
- wxcli/commands/partner_admins.py +141 -0
- wxcli/commands/partner_reports.py +188 -0
- wxcli/commands/partner_tags.py +216 -0
- wxcli/commands/people.py +279 -0
- wxcli/commands/person_call_settings.py +71 -0
- wxcli/commands/pstn.py +280 -0
- wxcli/commands/recording_report.py +154 -0
- wxcli/commands/report_templates.py +38 -0
- wxcli/commands/reports.py +144 -0
- wxcli/commands/resource_group_memberships.py +160 -0
- wxcli/commands/resource_groups.py +67 -0
- wxcli/commands/roles.py +63 -0
- wxcli/commands/room_tabs.py +160 -0
- wxcli/commands/rooms.py +225 -0
- wxcli/commands/scim_bulk.py +46 -0
- wxcli/commands/scim_groups.py +246 -0
- wxcli/commands/scim_schemas.py +82 -0
- wxcli/commands/scim_users.py +283 -0
- wxcli/commands/security_audit.py +54 -0
- wxcli/commands/service_apps.py +51 -0
- wxcli/commands/single_number_reach.py +209 -0
- wxcli/commands/team_memberships.py +155 -0
- wxcli/commands/teams.py +153 -0
- wxcli/commands/ucm_profile.py +42 -0
- wxcli/commands/update.py +219 -0
- wxcli/commands/user_settings.py +5116 -0
- wxcli/commands/video_mesh.py +1090 -0
- wxcli/commands/virtual_extensions.py +502 -0
- wxcli/commands/virtual_line_settings.py +2072 -0
- wxcli/commands/webhooks.py +176 -0
- wxcli/commands/wholesale_billing_reports.py +147 -0
- wxcli/commands/wholesale_provisioning.py +523 -0
- wxcli/commands/workspace_locations.py +339 -0
- wxcli/commands/workspace_metrics.py +103 -0
- wxcli/commands/workspace_personalization.py +70 -0
- wxcli/commands/workspace_settings.py +3347 -0
- wxcli/commands/workspaces.py +279 -0
- wxcli/commands/xapi.py +114 -0
- wxcli/config.py +139 -0
- wxcli/errors.py +77 -0
- wxcli/main.py +210 -0
- wxcli/migration/CLAUDE.md +106 -0
- wxcli/migration/__init__.py +1 -0
- wxcli/migration/advisory/CLAUDE.md +196 -0
- wxcli/migration/advisory/__init__.py +46 -0
- wxcli/migration/advisory/advisor.py +107 -0
- wxcli/migration/advisory/advisory_patterns.py +2552 -0
- wxcli/migration/advisory/recommendation_rules.py +835 -0
- wxcli/migration/cucm/CLAUDE.md +74 -0
- wxcli/migration/cucm/__init__.py +5 -0
- wxcli/migration/cucm/connection.py +265 -0
- wxcli/migration/cucm/discovery.py +225 -0
- wxcli/migration/cucm/extractors/__init__.py +1 -0
- wxcli/migration/cucm/extractors/announcements.py +92 -0
- wxcli/migration/cucm/extractors/base.py +90 -0
- wxcli/migration/cucm/extractors/device_profiles.py +98 -0
- wxcli/migration/cucm/extractors/devices.py +193 -0
- wxcli/migration/cucm/extractors/e911.py +103 -0
- wxcli/migration/cucm/extractors/features.py +309 -0
- wxcli/migration/cucm/extractors/helpers.py +73 -0
- wxcli/migration/cucm/extractors/informational.py +295 -0
- wxcli/migration/cucm/extractors/locations.py +221 -0
- wxcli/migration/cucm/extractors/moh.py +100 -0
- wxcli/migration/cucm/extractors/remote_destinations.py +96 -0
- wxcli/migration/cucm/extractors/routing.py +493 -0
- wxcli/migration/cucm/extractors/shared_lines.py +146 -0
- wxcli/migration/cucm/extractors/templates.py +229 -0
- wxcli/migration/cucm/extractors/tier4.py +173 -0
- wxcli/migration/cucm/extractors/users.py +240 -0
- wxcli/migration/cucm/extractors/voicemail.py +243 -0
- wxcli/migration/cucm/extractors/workspaces.py +88 -0
- wxcli/migration/cucm/unity_connection.py +304 -0
- wxcli/migration/execute/CLAUDE.md +372 -0
- wxcli/migration/execute/__init__.py +303 -0
- wxcli/migration/execute/batch.py +277 -0
- wxcli/migration/execute/dependency.py +483 -0
- wxcli/migration/execute/engine.py +964 -0
- wxcli/migration/execute/handlers.py +2232 -0
- wxcli/migration/execute/planner.py +1939 -0
- wxcli/migration/execute/runtime.py +571 -0
- wxcli/migration/export/CLAUDE.md +49 -0
- wxcli/migration/export/__init__.py +8 -0
- wxcli/migration/export/csv_export.py +126 -0
- wxcli/migration/export/deployment_plan.py +479 -0
- wxcli/migration/export/json_export.py +60 -0
- wxcli/migration/models.py +941 -0
- wxcli/migration/phone_models.py +50 -0
- wxcli/migration/preflight/CLAUDE.md +52 -0
- wxcli/migration/preflight/__init__.py +89 -0
- wxcli/migration/preflight/checks.py +911 -0
- wxcli/migration/preflight/runner.py +293 -0
- wxcli/migration/rate_limiter.py +144 -0
- wxcli/migration/report/CLAUDE.md +245 -0
- wxcli/migration/report/__init__.py +7 -0
- wxcli/migration/report/appendix.py +2437 -0
- wxcli/migration/report/assembler.py +183 -0
- wxcli/migration/report/charts.py +263 -0
- wxcli/migration/report/executive.py +776 -0
- wxcli/migration/report/explainer.py +704 -0
- wxcli/migration/report/helpers.py +74 -0
- wxcli/migration/report/ingest.py +152 -0
- wxcli/migration/report/notice_templates.py +192 -0
- wxcli/migration/report/score.py +375 -0
- wxcli/migration/report/styles.py +1052 -0
- wxcli/migration/report/user_diff.py +1052 -0
- wxcli/migration/report/user_notice.py +425 -0
- wxcli/migration/state.py +139 -0
- wxcli/migration/store.py +860 -0
- wxcli/migration/transform/CLAUDE.md +107 -0
- wxcli/migration/transform/__init__.py +12 -0
- wxcli/migration/transform/analysis_pipeline.py +453 -0
- wxcli/migration/transform/analyzers/__init__.py +142 -0
- wxcli/migration/transform/analyzers/css_permission.py +189 -0
- wxcli/migration/transform/analyzers/css_routing.py +335 -0
- wxcli/migration/transform/analyzers/device_compatibility.py +122 -0
- wxcli/migration/transform/analyzers/dn_ambiguity.py +131 -0
- wxcli/migration/transform/analyzers/duplicate_user.py +221 -0
- wxcli/migration/transform/analyzers/extension_conflict.py +173 -0
- wxcli/migration/transform/analyzers/feature_approximation.py +227 -0
- wxcli/migration/transform/analyzers/layout_overflow.py +174 -0
- wxcli/migration/transform/analyzers/location_ambiguity.py +184 -0
- wxcli/migration/transform/analyzers/missing_data.py +300 -0
- wxcli/migration/transform/analyzers/selective_call_handling.py +394 -0
- wxcli/migration/transform/analyzers/shared_line.py +170 -0
- wxcli/migration/transform/analyzers/voicemail_compatibility.py +267 -0
- wxcli/migration/transform/analyzers/workspace_license.py +212 -0
- wxcli/migration/transform/cross_reference.py +1136 -0
- wxcli/migration/transform/cucm_pattern.py +246 -0
- wxcli/migration/transform/decisions.py +301 -0
- wxcli/migration/transform/e164.py +145 -0
- wxcli/migration/transform/engine.py +256 -0
- wxcli/migration/transform/mappers/CLAUDE.md +123 -0
- wxcli/migration/transform/mappers/__init__.py +76 -0
- wxcli/migration/transform/mappers/announcement_mapper.py +122 -0
- wxcli/migration/transform/mappers/base.py +190 -0
- wxcli/migration/transform/mappers/button_template_mapper.py +186 -0
- wxcli/migration/transform/mappers/call_forwarding_mapper.py +228 -0
- wxcli/migration/transform/mappers/call_settings_mapper.py +144 -0
- wxcli/migration/transform/mappers/css_mapper.py +868 -0
- wxcli/migration/transform/mappers/dect_mapper.py +367 -0
- wxcli/migration/transform/mappers/device_layout_mapper.py +289 -0
- wxcli/migration/transform/mappers/device_mapper.py +227 -0
- wxcli/migration/transform/mappers/device_profile_mapper.py +190 -0
- wxcli/migration/transform/mappers/device_settings_mapper.py +464 -0
- wxcli/migration/transform/mappers/e911_mapper.py +129 -0
- wxcli/migration/transform/mappers/ecbn_mapper.py +263 -0
- wxcli/migration/transform/mappers/executive_assistant_mapper.py +156 -0
- wxcli/migration/transform/mappers/feature_mapper.py +1179 -0
- wxcli/migration/transform/mappers/line_mapper.py +310 -0
- wxcli/migration/transform/mappers/location_mapper.py +235 -0
- wxcli/migration/transform/mappers/moh_mapper.py +114 -0
- wxcli/migration/transform/mappers/monitoring_mapper.py +221 -0
- wxcli/migration/transform/mappers/receptionist_mapper.py +245 -0
- wxcli/migration/transform/mappers/routing_mapper.py +821 -0
- wxcli/migration/transform/mappers/snr_mapper.py +201 -0
- wxcli/migration/transform/mappers/softkey_mapper.py +205 -0
- wxcli/migration/transform/mappers/user_mapper.py +264 -0
- wxcli/migration/transform/mappers/voicemail_group_mapper.py +286 -0
- wxcli/migration/transform/mappers/voicemail_mapper.py +514 -0
- wxcli/migration/transform/mappers/workspace_mapper.py +464 -0
- wxcli/migration/transform/normalizers.py +2008 -0
- wxcli/migration/transform/pattern_converter.py +106 -0
- wxcli/migration/transform/pipeline.py +271 -0
- wxcli/migration/transform/rules.py +255 -0
- wxcli/org_health/CLAUDE.md +63 -0
- wxcli/org_health/__init__.py +7 -0
- wxcli/org_health/analyze.py +93 -0
- wxcli/org_health/checks.py +458 -0
- wxcli/org_health/collector.py +62 -0
- wxcli/org_health/models.py +78 -0
- wxcli/org_health/report.py +300 -0
- wxcli/output.py +101 -0
- wxcli-1.2.0.dist-info/METADATA +505 -0
- wxcli-1.2.0.dist-info/RECORD +413 -0
- wxcli-1.2.0.dist-info/WHEEL +5 -0
- wxcli-1.2.0.dist-info/entry_points.txt +2 -0
- wxcli-1.2.0.dist-info/licenses/LICENSE +191 -0
- wxcli-1.2.0.dist-info/scm_file_list.json +751 -0
- wxcli-1.2.0.dist-info/scm_version.json +8 -0
- wxcli-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2232 @@
|
|
|
1
|
+
"""Operation handlers — pure functions mapping canonical data to API requests.
|
|
2
|
+
|
|
3
|
+
Each handler takes:
|
|
4
|
+
data: dict — canonical object data from the migration store
|
|
5
|
+
resolved_deps: dict — {canonical_id: webex_id} for completed dependencies
|
|
6
|
+
ctx: dict — session context (orgId, CALLING_LICENSE_ID, etc.)
|
|
7
|
+
|
|
8
|
+
Each handler returns:
|
|
9
|
+
list[tuple[str, str, dict | None]] — [(method, url, body), ...]
|
|
10
|
+
|
|
11
|
+
Most handlers return a single tuple. Multi-call operations (e.g., user
|
|
12
|
+
configure_settings) return multiple tuples to be executed sequentially.
|
|
13
|
+
|
|
14
|
+
Handlers are pure functions — no IO, no side effects, fully testable.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any
|
|
22
|
+
from urllib.parse import urlencode
|
|
23
|
+
|
|
24
|
+
from wxcli.migration.phone_models import (
|
|
25
|
+
PHONEOS_LINE_KEY_COUNTS,
|
|
26
|
+
PHONEOS_MODEL_NUMBERS,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
BASE = "https://webexapis.com/v1"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class SkippedResult:
|
|
36
|
+
"""Sentinel returned by handlers when a required dependency is missing.
|
|
37
|
+
|
|
38
|
+
Engine intercepts this and calls ``update_op_status(..., 'skipped',
|
|
39
|
+
error_message=reason)`` instead of marking the op completed. Use when a
|
|
40
|
+
hard prerequisite (e.g., upstream Webex ID not resolved) is missing. For
|
|
41
|
+
legitimate no-ops ("feature disabled", "nothing to configure") keep
|
|
42
|
+
returning ``[]`` — the engine marks those completed.
|
|
43
|
+
"""
|
|
44
|
+
reason: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def skipped(reason: str) -> "SkippedResult":
|
|
48
|
+
"""Return a SkippedResult sentinel with the given reason."""
|
|
49
|
+
return SkippedResult(reason=reason)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Handlers may return either a list of API calls or a SkippedResult sentinel.
|
|
53
|
+
# Wave 2 will migrate individual guard-clause sites from ``return []`` to
|
|
54
|
+
# ``return skipped(...)`` where the ``[]`` truly means "dependency missing".
|
|
55
|
+
HandlerResult = list[tuple[str, str, dict | None]] | SkippedResult
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _url(path: str, ctx: dict, query: dict | None = None) -> str:
|
|
59
|
+
"""Build full URL with optional orgId injection."""
|
|
60
|
+
params = {}
|
|
61
|
+
if ctx.get("orgId"):
|
|
62
|
+
params["orgId"] = ctx["orgId"]
|
|
63
|
+
if query:
|
|
64
|
+
params.update(query)
|
|
65
|
+
url = f"{BASE}{path}"
|
|
66
|
+
if params:
|
|
67
|
+
url += f"?{urlencode(params)}"
|
|
68
|
+
return url
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve(deps: dict, canonical_id: str) -> str | None:
|
|
72
|
+
"""Look up a Webex ID from resolved_deps by canonical_id."""
|
|
73
|
+
return deps.get(canonical_id)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve_location(data: dict, deps: dict) -> str | None:
|
|
77
|
+
"""Resolve location Webex ID from canonical location_id."""
|
|
78
|
+
loc_cid = data.get("location_id")
|
|
79
|
+
if not loc_cid:
|
|
80
|
+
return None
|
|
81
|
+
return deps.get(loc_cid)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# PhoneOS model membership comes from ``wxcli.migration.phone_models`` —
|
|
85
|
+
# single source of truth shared with device_mapper / softkey_mapper /
|
|
86
|
+
# button_template_mapper / layout_overflow.
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _normalize_device_model(model: str) -> str:
|
|
90
|
+
"""Normalize a canonical device model string for the Webex devices API.
|
|
91
|
+
|
|
92
|
+
PhoneOS phones (9800-series + 8875) must NOT carry the "DMS " prefix —
|
|
93
|
+
the device-create and activation-code APIs reject it with
|
|
94
|
+
"Device model is not supported." All other phones require the "DMS "
|
|
95
|
+
prefix. The " IP Phone " token is always collapsed because CUCM models
|
|
96
|
+
may arrive as "Cisco IP Phone 8851" while Webex expects "Cisco 8851".
|
|
97
|
+
"""
|
|
98
|
+
# Defensive early-return: callers generally guard with ``if model:`` but
|
|
99
|
+
# an empty string used to fall through to the classic-MPP branch and
|
|
100
|
+
# return "DMS ", which is invalid.
|
|
101
|
+
if not model:
|
|
102
|
+
return model
|
|
103
|
+
# Always collapse " IP Phone " first so downstream checks see the
|
|
104
|
+
# concise form ("Cisco 9841" vs "Cisco IP Phone 9841").
|
|
105
|
+
model = model.replace(" IP Phone ", " ")
|
|
106
|
+
is_phoneos = any(s in model for s in PHONEOS_MODEL_NUMBERS)
|
|
107
|
+
if is_phoneos:
|
|
108
|
+
# Strip any pre-existing "DMS " prefix — PhoneOS rejects it.
|
|
109
|
+
if model.startswith("DMS "):
|
|
110
|
+
model = model[len("DMS "):]
|
|
111
|
+
else:
|
|
112
|
+
if not model.startswith("DMS "):
|
|
113
|
+
model = f"DMS {model}"
|
|
114
|
+
return model
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _resolve_location_from_deps(deps: dict) -> str | None:
|
|
118
|
+
"""Fallback: find a location webex_id in resolved_deps by prefix.
|
|
119
|
+
Used when the canonical model doesn't have a location_id field."""
|
|
120
|
+
for cid, wid in deps.items():
|
|
121
|
+
if cid.startswith("location:") and wid:
|
|
122
|
+
return wid
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _resolve_agents(
|
|
127
|
+
data: dict, deps: dict, field: str = "agents"
|
|
128
|
+
) -> tuple[list[dict], list[str]]:
|
|
129
|
+
"""Resolve agent canonical IDs to Webex person IDs (object format for HG/CQ).
|
|
130
|
+
|
|
131
|
+
Returns a tuple ``(resolved, skipped)``:
|
|
132
|
+
|
|
133
|
+
* ``resolved`` — list of ``{"id": webex_id}`` dicts for agents whose canonical
|
|
134
|
+
IDs successfully resolved in ``deps``.
|
|
135
|
+
* ``skipped`` — list of canonical IDs that FAILED to resolve. Callers should
|
|
136
|
+
log a warning for each skipped agent and, where possible, record them in
|
|
137
|
+
the op metadata.
|
|
138
|
+
|
|
139
|
+
Per spec resolved decision #3, hunt groups/call queues proceed with partial
|
|
140
|
+
membership when some agents don't resolve — the alternative (no HG at all)
|
|
141
|
+
routes calls to nobody. The operator can add missing members post-migration.
|
|
142
|
+
|
|
143
|
+
Wave 3 will wire full op metadata recording; today we log-only.
|
|
144
|
+
"""
|
|
145
|
+
agents: list[dict] = []
|
|
146
|
+
skipped_agents: list[str] = []
|
|
147
|
+
for cid in data.get(field, []):
|
|
148
|
+
wid = deps.get(cid)
|
|
149
|
+
if wid:
|
|
150
|
+
agents.append({"id": wid})
|
|
151
|
+
else:
|
|
152
|
+
skipped_agents.append(cid)
|
|
153
|
+
return agents, skipped_agents
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _resolve_agent_ids(data: dict, deps: dict, field: str = "agents") -> list[str]:
|
|
157
|
+
"""Resolve agent canonical IDs to plain Webex ID strings (for pickup/paging)."""
|
|
158
|
+
ids = []
|
|
159
|
+
for cid in data.get(field, []):
|
|
160
|
+
wid = deps.get(cid)
|
|
161
|
+
if wid:
|
|
162
|
+
ids.append(wid)
|
|
163
|
+
return ids
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _find_webex_id(data: dict, deps: dict) -> str | None:
|
|
167
|
+
"""Resolve the Webex ID for the canonical object referenced by `data`.
|
|
168
|
+
|
|
169
|
+
Looks up `data['canonical_id']` in `deps`. Returns None if the feature
|
|
170
|
+
has not been created yet (handler should return [] in that case).
|
|
171
|
+
"""
|
|
172
|
+
cid = data.get("canonical_id")
|
|
173
|
+
if not cid:
|
|
174
|
+
return None
|
|
175
|
+
return deps.get(cid)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Tier 0: Infrastructure
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def handle_location_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
183
|
+
addr = data.get("address", {})
|
|
184
|
+
body: dict[str, Any] = {
|
|
185
|
+
"name": data.get("name"),
|
|
186
|
+
"timeZone": data.get("time_zone"),
|
|
187
|
+
"preferredLanguage": data.get("preferred_language"),
|
|
188
|
+
"announcementLanguage": data.get("announcement_language"),
|
|
189
|
+
"address": {
|
|
190
|
+
"address1": addr.get("address1"),
|
|
191
|
+
"city": addr.get("city"),
|
|
192
|
+
"state": addr.get("state"),
|
|
193
|
+
"postalCode": addr.get("postal_code"),
|
|
194
|
+
"country": addr.get("country"),
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
# Optional fields
|
|
198
|
+
if addr.get("address2"):
|
|
199
|
+
body["address"]["address2"] = addr["address2"]
|
|
200
|
+
return [("POST", _url("/locations", ctx), body)]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def handle_location_enable_calling(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
204
|
+
# Resolve this op's own location by canonical_id rather than scanning
|
|
205
|
+
# deps for the first "location:" prefix. Runtime injects ALL completed
|
|
206
|
+
# location ops into resolved_deps (see runtime.py:get_next_batch), so
|
|
207
|
+
# the scan would pick whichever location happens to be first in dict
|
|
208
|
+
# iteration order — not necessarily this op's own location.
|
|
209
|
+
loc_cid = data.get("canonical_id")
|
|
210
|
+
loc_wid = deps.get(loc_cid) if loc_cid else None
|
|
211
|
+
body: dict[str, Any] = {
|
|
212
|
+
"id": loc_wid,
|
|
213
|
+
"name": data.get("name"),
|
|
214
|
+
"timeZone": data.get("time_zone"),
|
|
215
|
+
"preferredLanguage": data.get("preferred_language"),
|
|
216
|
+
"announcementLanguage": data.get("announcement_language"),
|
|
217
|
+
}
|
|
218
|
+
# Address is required when re-enabling calling on an existing location
|
|
219
|
+
addr = data.get("address")
|
|
220
|
+
if addr:
|
|
221
|
+
body["address"] = {
|
|
222
|
+
"address1": addr.get("address1"),
|
|
223
|
+
"city": addr.get("city"),
|
|
224
|
+
"state": addr.get("state"),
|
|
225
|
+
"postalCode": addr.get("postal_code"),
|
|
226
|
+
"country": addr.get("country"),
|
|
227
|
+
}
|
|
228
|
+
return [("POST", _url("/telephony/config/locations", ctx), body)]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
# Tier 1: Routing backbone
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def handle_trunk_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
236
|
+
loc_wid = _resolve_location(data, deps) or _resolve_location_from_deps(deps)
|
|
237
|
+
body: dict[str, Any] = {
|
|
238
|
+
"name": data.get("name"),
|
|
239
|
+
"locationId": loc_wid,
|
|
240
|
+
"trunkType": data.get("trunk_type", "REGISTERING"),
|
|
241
|
+
"password": data.get("password"),
|
|
242
|
+
}
|
|
243
|
+
if data.get("address"):
|
|
244
|
+
body["address"] = data["address"]
|
|
245
|
+
if data.get("domain"):
|
|
246
|
+
body["domain"] = data["domain"]
|
|
247
|
+
if data.get("port"):
|
|
248
|
+
body["port"] = data["port"]
|
|
249
|
+
if data.get("max_concurrent_calls"):
|
|
250
|
+
body["maxConcurrentCalls"] = data["max_concurrent_calls"]
|
|
251
|
+
if data.get("dual_identity_support_enabled") is not None:
|
|
252
|
+
body["dualIdentitySupportEnabled"] = data["dual_identity_support_enabled"]
|
|
253
|
+
return [("POST", _url("/telephony/config/premisePstn/trunks", ctx), body)]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def handle_route_group_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
257
|
+
gateways = []
|
|
258
|
+
for gw in data.get("local_gateways", []):
|
|
259
|
+
trunk_cid = gw.get("trunk_canonical_id")
|
|
260
|
+
trunk_wid = deps.get(trunk_cid) if trunk_cid else None
|
|
261
|
+
if trunk_wid:
|
|
262
|
+
gateways.append({
|
|
263
|
+
"id": trunk_wid,
|
|
264
|
+
"priority": gw.get("priority", 1),
|
|
265
|
+
})
|
|
266
|
+
body: dict[str, Any] = {
|
|
267
|
+
"name": data.get("name"),
|
|
268
|
+
"localGateways": gateways,
|
|
269
|
+
}
|
|
270
|
+
return [("POST", _url("/telephony/config/premisePstn/routeGroups", ctx), body)]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def handle_route_list_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
274
|
+
loc_wid = _resolve_location(data, deps) or _resolve_location_from_deps(deps)
|
|
275
|
+
rg_cid = data.get("route_group_id", "")
|
|
276
|
+
rg_wid = deps.get(rg_cid)
|
|
277
|
+
if not rg_wid:
|
|
278
|
+
return skipped(
|
|
279
|
+
f"route group {rg_cid!r} not resolved for route list "
|
|
280
|
+
f"{data.get('name')!r}"
|
|
281
|
+
)
|
|
282
|
+
body: dict[str, Any] = {
|
|
283
|
+
"name": data.get("name"),
|
|
284
|
+
"locationId": loc_wid,
|
|
285
|
+
"routeGroupId": rg_wid,
|
|
286
|
+
}
|
|
287
|
+
return [("POST", _url("/telephony/config/premisePstn/routeLists", ctx), body)]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def handle_route_list_configure_numbers(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
291
|
+
rl_cid = data.get("canonical_id", "")
|
|
292
|
+
rl_wid = deps.get(rl_cid)
|
|
293
|
+
if not rl_wid:
|
|
294
|
+
return skipped(
|
|
295
|
+
f"route list {rl_cid!r} not resolved — cannot configure numbers"
|
|
296
|
+
)
|
|
297
|
+
numbers = data.get("numbers", [])
|
|
298
|
+
if not numbers:
|
|
299
|
+
return [] # true no-op: no numbers to configure
|
|
300
|
+
body: dict[str, Any] = {
|
|
301
|
+
"numbers": [{"number": n, "action": "ADD"} for n in numbers],
|
|
302
|
+
}
|
|
303
|
+
return [("PUT", _url(f"/telephony/config/premisePstn/routeLists/{rl_wid}/numbers", ctx), body)]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def handle_operating_mode_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
307
|
+
body: dict[str, Any] = {
|
|
308
|
+
"name": data.get("name"),
|
|
309
|
+
"level": data.get("level", "ORGANIZATION"),
|
|
310
|
+
}
|
|
311
|
+
schedule_type = data.get("schedule_type")
|
|
312
|
+
if schedule_type:
|
|
313
|
+
body["type"] = schedule_type
|
|
314
|
+
|
|
315
|
+
# Convert canonical same_hours_daily {startTime, endTime} to Webex API format
|
|
316
|
+
# Webex API: {mondayToFriday: {enabled, allDayEnabled, startTime?, endTime?}, saturdayToSunday: ...}
|
|
317
|
+
same = data.get("same_hours_daily")
|
|
318
|
+
if same:
|
|
319
|
+
start = same.get("startTime", "00:00")
|
|
320
|
+
end = same.get("endTime", "24:00")
|
|
321
|
+
all_day = (start == "00:00" and end in ("24:00", "00:00"))
|
|
322
|
+
day_entry: dict[str, Any] = {"enabled": True, "allDayEnabled": all_day}
|
|
323
|
+
if not all_day:
|
|
324
|
+
day_entry["startTime"] = start
|
|
325
|
+
day_entry["endTime"] = end
|
|
326
|
+
body["sameHoursDaily"] = {
|
|
327
|
+
"mondayToFriday": day_entry,
|
|
328
|
+
"saturdayToSunday": day_entry,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
# Convert canonical different_hours_daily {day_N: {startTime, endTime}} or
|
|
332
|
+
# {monday: ..., tuesday: ...} to Webex API format
|
|
333
|
+
diff = data.get("different_hours_daily")
|
|
334
|
+
if diff:
|
|
335
|
+
_DAY_NAMES = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
|
|
336
|
+
# Canonical may use day_0..day_6 keys (numeric) or already-named keys
|
|
337
|
+
webex_diff: dict[str, Any] = {}
|
|
338
|
+
for key, val in diff.items():
|
|
339
|
+
if key.startswith("day_"):
|
|
340
|
+
idx = int(key.split("_", 1)[1])
|
|
341
|
+
day_name = _DAY_NAMES[idx] if idx < len(_DAY_NAMES) else key
|
|
342
|
+
else:
|
|
343
|
+
day_name = key # already named
|
|
344
|
+
start = val.get("startTime", "00:00")
|
|
345
|
+
end = val.get("endTime", "17:00")
|
|
346
|
+
all_day = (start == "00:00" and end in ("24:00", "00:00"))
|
|
347
|
+
entry: dict[str, Any] = {"enabled": True, "allDayEnabled": all_day}
|
|
348
|
+
if not all_day:
|
|
349
|
+
entry["startTime"] = start
|
|
350
|
+
entry["endTime"] = end
|
|
351
|
+
webex_diff[day_name] = entry
|
|
352
|
+
body["differentHoursDaily"] = webex_diff
|
|
353
|
+
|
|
354
|
+
if data.get("holidays"):
|
|
355
|
+
body["holidays"] = data["holidays"]
|
|
356
|
+
return [("POST", _url("/telephony/config/operatingModes", ctx), body)]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
# Tier 1: Line key templates (org-wide, no dependencies)
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
def handle_line_key_template_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
364
|
+
# Skip templates with no Webex device model mapping (e.g., Standard Analog, ATA 191)
|
|
365
|
+
device_model = data.get("device_model")
|
|
366
|
+
if not device_model:
|
|
367
|
+
return []
|
|
368
|
+
|
|
369
|
+
# PhoneOS 9800-series and 8875 use "Cisco {model}" not "DMS Cisco {model}"
|
|
370
|
+
# The ButtonTemplateMapper stores "DMS Cisco {model}" for all phones, but
|
|
371
|
+
# PhoneOS phones require the non-DMS model name for the lineKeyTemplates API.
|
|
372
|
+
# Also track the max physical line key count for PhoneOS phones so we can
|
|
373
|
+
# split overflow indices from line_keys into kem_keys (mapper puts all button
|
|
374
|
+
# indices into line_keys regardless of phone key count).
|
|
375
|
+
#
|
|
376
|
+
# Counts come from ``wxcli.migration.phone_models.PHONEOS_LINE_KEY_COUNTS``
|
|
377
|
+
# — shared with button_template_mapper and layout_overflow.
|
|
378
|
+
phoneos_max: int | None = None
|
|
379
|
+
for phoneos_model, max_lk in PHONEOS_LINE_KEY_COUNTS.items():
|
|
380
|
+
if device_model == f"DMS Cisco {phoneos_model}":
|
|
381
|
+
device_model = f"Cisco {phoneos_model}"
|
|
382
|
+
phoneos_max = max_lk
|
|
383
|
+
break
|
|
384
|
+
|
|
385
|
+
def _map_key(k: dict, idx_field: str, mod_field: str | None = None) -> dict:
|
|
386
|
+
key_prefix = "kemKey" if mod_field else "lineKey"
|
|
387
|
+
key_type = k["key_type"]
|
|
388
|
+
# SPEED_DIAL without a value is rejected by the API (Error 27650).
|
|
389
|
+
# A template-level speed dial slot with no assigned number is effectively OPEN.
|
|
390
|
+
if key_type == "SPEED_DIAL" and not k.get("value"):
|
|
391
|
+
key_type = "OPEN"
|
|
392
|
+
entry: dict[str, Any] = {idx_field: k["index"], f"{key_prefix}Type": key_type}
|
|
393
|
+
if mod_field:
|
|
394
|
+
entry[mod_field] = k.get("module_index", 1)
|
|
395
|
+
if k.get("label"):
|
|
396
|
+
entry[f"{key_prefix}Label"] = k["label"]
|
|
397
|
+
if k.get("value"):
|
|
398
|
+
entry[f"{key_prefix}Value"] = k["value"]
|
|
399
|
+
return entry
|
|
400
|
+
|
|
401
|
+
all_line_keys = [k for k in data.get("line_keys", []) if k.get("key_type") != "UNMAPPED"]
|
|
402
|
+
|
|
403
|
+
# For PhoneOS phones, the mapper may place KEM button indices (> max_lk) in
|
|
404
|
+
# line_keys instead of kem_keys. Split them here: indices <= phoneos_max → lineKeys,
|
|
405
|
+
# indices > phoneos_max → kemKeys (re-index from 1 within the KEM range).
|
|
406
|
+
overflow_as_kem: list[dict] = []
|
|
407
|
+
if phoneos_max is not None:
|
|
408
|
+
phone_line_keys = [k for k in all_line_keys if k["index"] <= phoneos_max]
|
|
409
|
+
overflow_raw = sorted(
|
|
410
|
+
[k for k in all_line_keys if k["index"] > phoneos_max],
|
|
411
|
+
key=lambda x: x["index"],
|
|
412
|
+
)
|
|
413
|
+
# Re-index KEM overflow starting at 1
|
|
414
|
+
for new_idx, k in enumerate(overflow_raw, 1):
|
|
415
|
+
overflow_as_kem.append({**k, "index": new_idx})
|
|
416
|
+
all_line_keys = phone_line_keys
|
|
417
|
+
else:
|
|
418
|
+
phone_line_keys = all_line_keys
|
|
419
|
+
|
|
420
|
+
line_keys = [_map_key(k, "lineKeyIndex") for k in all_line_keys]
|
|
421
|
+
body: dict[str, Any] = {
|
|
422
|
+
"templateName": data.get("name"),
|
|
423
|
+
"deviceModel": device_model,
|
|
424
|
+
"lineKeys": line_keys,
|
|
425
|
+
}
|
|
426
|
+
if data.get("kem_module_type"):
|
|
427
|
+
body["kemModuleType"] = data["kem_module_type"]
|
|
428
|
+
kem_keys = [
|
|
429
|
+
_map_key(k, "kemKeyIndex", "kemModuleIndex")
|
|
430
|
+
for k in data.get("kem_keys", [])
|
|
431
|
+
if k.get("key_type") != "UNMAPPED"
|
|
432
|
+
]
|
|
433
|
+
# Append overflow line_keys that were re-routed to KEM
|
|
434
|
+
kem_keys.extend([_map_key(k, "kemKeyIndex", "kemModuleIndex") for k in overflow_as_kem])
|
|
435
|
+
if kem_keys:
|
|
436
|
+
body["kemKeys"] = kem_keys
|
|
437
|
+
return [("POST", _url("/telephony/config/devices/lineKeyTemplates", ctx), body)]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def handle_schedule_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
441
|
+
"""Location schedule (businessHours or holidays) — used by AAs."""
|
|
442
|
+
loc_wid = _resolve_location(data, deps)
|
|
443
|
+
body: dict[str, Any] = {
|
|
444
|
+
"name": data.get("name"),
|
|
445
|
+
"type": data.get("schedule_type", "businessHours"),
|
|
446
|
+
}
|
|
447
|
+
if data.get("events"):
|
|
448
|
+
body["events"] = data["events"]
|
|
449
|
+
return [("POST", _url(f"/telephony/config/locations/{loc_wid}/schedules", ctx), body)]
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ---------------------------------------------------------------------------
|
|
453
|
+
# Tier 2: People & Routing
|
|
454
|
+
# ---------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
def handle_user_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
457
|
+
loc_wid = _resolve_location(data, deps)
|
|
458
|
+
body: dict[str, Any] = {
|
|
459
|
+
"emails": data.get("emails", []),
|
|
460
|
+
"firstName": data.get("first_name"),
|
|
461
|
+
"lastName": data.get("last_name"),
|
|
462
|
+
"displayName": data.get("display_name"),
|
|
463
|
+
"locationId": loc_wid,
|
|
464
|
+
"extension": data.get("extension"),
|
|
465
|
+
}
|
|
466
|
+
license_id = ctx.get("CALLING_LICENSE_ID")
|
|
467
|
+
if license_id:
|
|
468
|
+
body["licenses"] = [license_id]
|
|
469
|
+
if data.get("phone_numbers"):
|
|
470
|
+
body["phoneNumbers"] = data["phone_numbers"]
|
|
471
|
+
if data.get("department"):
|
|
472
|
+
body["department"] = data["department"]
|
|
473
|
+
if data.get("title"):
|
|
474
|
+
body["title"] = data["title"]
|
|
475
|
+
return [("POST", _url("/people", ctx, {"callingData": "true"}), body)]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def handle_workspace_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
479
|
+
loc_wid = _resolve_location(data, deps)
|
|
480
|
+
calling_config: dict[str, Any] = {
|
|
481
|
+
"type": data.get("calling_type", "webexCalling"),
|
|
482
|
+
"webexCalling": {
|
|
483
|
+
"locationId": loc_wid,
|
|
484
|
+
},
|
|
485
|
+
}
|
|
486
|
+
if data.get("extension"):
|
|
487
|
+
calling_config["webexCalling"]["extension"] = data["extension"]
|
|
488
|
+
if data.get("phone_number"):
|
|
489
|
+
calling_config["webexCalling"]["phoneNumber"] = data["phone_number"]
|
|
490
|
+
ws_license = ctx.get("WORKSPACE_LICENSE_ID")
|
|
491
|
+
if ws_license:
|
|
492
|
+
calling_config["webexCalling"]["licenses"] = [ws_license]
|
|
493
|
+
body: dict[str, Any] = {
|
|
494
|
+
"displayName": data.get("display_name"),
|
|
495
|
+
"supportedDevices": data.get("supported_devices", "phones"),
|
|
496
|
+
"type": data.get("workspace_type", "other"),
|
|
497
|
+
"calling": calling_config,
|
|
498
|
+
}
|
|
499
|
+
if data.get("hotdesking_status"):
|
|
500
|
+
body["hotdeskingStatus"] = data["hotdesking_status"]
|
|
501
|
+
return [("POST", _url("/workspaces", ctx), body)]
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def handle_workspace_assign_number(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
505
|
+
# Resolve target workspace by this op's own canonical_id rather than
|
|
506
|
+
# scanning deps for the first "workspace:" prefix. Multiple workspaces
|
|
507
|
+
# in deps (edge case, but possible via cross-object rules) would cause
|
|
508
|
+
# the scan to misaddress the PUT and assign the number to the wrong ws.
|
|
509
|
+
ws_cid = data.get("canonical_id")
|
|
510
|
+
ws_wid = deps.get(ws_cid) if ws_cid else None
|
|
511
|
+
if not ws_wid:
|
|
512
|
+
return skipped(
|
|
513
|
+
f"workspace not resolved for assign_number "
|
|
514
|
+
f"(display_name={data.get('display_name')!r})"
|
|
515
|
+
)
|
|
516
|
+
if not data.get("phone_number"):
|
|
517
|
+
return [] # true no-op: no DID configured to assign
|
|
518
|
+
body: dict[str, Any] = {"phoneNumbers": [{"type": "work", "value": data["phone_number"]}]}
|
|
519
|
+
return [("PUT", _url(f"/workspaces/{ws_wid}", ctx), body)]
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def handle_device_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
523
|
+
body: dict[str, Any] = {}
|
|
524
|
+
if data.get("mac"):
|
|
525
|
+
body["mac"] = data["mac"]
|
|
526
|
+
model = data.get("model")
|
|
527
|
+
if model:
|
|
528
|
+
# PhoneOS phones (9800-series + 8875) reject the "DMS " prefix;
|
|
529
|
+
# all other phones require it. See _normalize_device_model.
|
|
530
|
+
body["model"] = _normalize_device_model(model)
|
|
531
|
+
# Resolve owner: person or workspace
|
|
532
|
+
owner_cid = data.get("owner_canonical_id")
|
|
533
|
+
if owner_cid:
|
|
534
|
+
owner_wid = deps.get(owner_cid)
|
|
535
|
+
if owner_wid:
|
|
536
|
+
if owner_cid.startswith("user:"):
|
|
537
|
+
body["personId"] = owner_wid
|
|
538
|
+
elif owner_cid.startswith("workspace:"):
|
|
539
|
+
body["workspaceId"] = owner_wid
|
|
540
|
+
return [("POST", _url("/devices", ctx), body)]
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def handle_device_create_activation_code(
|
|
544
|
+
data: dict, deps: dict, ctx: dict,
|
|
545
|
+
) -> HandlerResult:
|
|
546
|
+
"""Generate an activation code for a firmware-convertible device.
|
|
547
|
+
|
|
548
|
+
Used for CUCM-sourced phones classified as DeviceCompatibilityTier.CONVERTIBLE
|
|
549
|
+
(8845/8851/8865/7821/7841/7861). These phones are factory-reset, reflashed
|
|
550
|
+
with MPP firmware, and then registered to Webex via the returned activation
|
|
551
|
+
code. Unlike handle_device_create, this flow:
|
|
552
|
+
|
|
553
|
+
- does NOT send the MAC address (the code, not the MAC, establishes identity)
|
|
554
|
+
- REQUIRES `model` for phones (absent → code only works on RoomOS devices)
|
|
555
|
+
- normalizes the model string to the Webex "DMS <name>" format
|
|
556
|
+
"""
|
|
557
|
+
body: dict[str, Any] = {}
|
|
558
|
+
|
|
559
|
+
model = data.get("model")
|
|
560
|
+
if model:
|
|
561
|
+
# CanonicalDevice.model may arrive as either "Cisco 8851" or
|
|
562
|
+
# "Cisco IP Phone 8851" (both are recognized as convertible in
|
|
563
|
+
# cross_reference._CONVERTIBLE_PATTERNS). The Webex activation
|
|
564
|
+
# code API expects the concise form and rejects the "DMS " prefix
|
|
565
|
+
# on PhoneOS models — see _normalize_device_model.
|
|
566
|
+
body["model"] = _normalize_device_model(model)
|
|
567
|
+
|
|
568
|
+
owner_cid = data.get("owner_canonical_id")
|
|
569
|
+
if owner_cid:
|
|
570
|
+
owner_wid = deps.get(owner_cid)
|
|
571
|
+
if owner_wid:
|
|
572
|
+
if owner_cid.startswith("user:"):
|
|
573
|
+
body["personId"] = owner_wid
|
|
574
|
+
elif owner_cid.startswith("workspace:"):
|
|
575
|
+
body["workspaceId"] = owner_wid
|
|
576
|
+
|
|
577
|
+
return [("POST", _url("/devices/activationCode", ctx), body)]
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# ---------------------------------------------------------------------------
|
|
581
|
+
# Tier 3: Routing patterns
|
|
582
|
+
# ---------------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
def handle_dial_plan_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
585
|
+
route_cid = data.get("route_id")
|
|
586
|
+
route_wid = deps.get(route_cid) if route_cid else None
|
|
587
|
+
# Create takes plain strings; modify (PUT) takes {"dialPattern": p, "action": "ADD"}
|
|
588
|
+
patterns = data.get("dial_patterns", [])
|
|
589
|
+
body: dict[str, Any] = {
|
|
590
|
+
"name": data.get("name"),
|
|
591
|
+
"routeId": route_wid,
|
|
592
|
+
"routeType": data.get("route_type", "TRUNK"),
|
|
593
|
+
"dialPatterns": patterns,
|
|
594
|
+
}
|
|
595
|
+
return [("POST", _url("/telephony/config/premisePstn/dialPlans", ctx), body)]
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def handle_translation_pattern_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
599
|
+
body: dict[str, Any] = {
|
|
600
|
+
"name": data.get("name"),
|
|
601
|
+
"matchingPattern": data.get("matching_pattern"),
|
|
602
|
+
"replacementPattern": data.get("replacement_pattern"),
|
|
603
|
+
}
|
|
604
|
+
return [("POST", _url("/telephony/config/callRouting/translationPatterns", ctx), body)]
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
# Tier 4: Call features
|
|
609
|
+
# ---------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
def handle_hunt_group_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
612
|
+
loc_wid = _resolve_location(data, deps)
|
|
613
|
+
agents, skipped_agents = _resolve_agents(data, deps, "agents")
|
|
614
|
+
# Per spec resolved decision #3: create the hunt group with partial
|
|
615
|
+
# membership when some agents don't resolve. Log a WARNING for each
|
|
616
|
+
# skipped agent so the operator can reconcile post-migration.
|
|
617
|
+
# Wave 3 will add full op-metadata recording; today this is log-only.
|
|
618
|
+
if skipped_agents:
|
|
619
|
+
hg_name = data.get("name", "<unnamed>")
|
|
620
|
+
for cid in skipped_agents:
|
|
621
|
+
logger.warning(
|
|
622
|
+
"hunt_group '%s': agent %s not resolved — excluded from create "
|
|
623
|
+
"(add manually post-migration)",
|
|
624
|
+
hg_name, cid,
|
|
625
|
+
)
|
|
626
|
+
body: dict[str, Any] = {
|
|
627
|
+
"name": data.get("name"),
|
|
628
|
+
"extension": data.get("extension"),
|
|
629
|
+
}
|
|
630
|
+
if data.get("phone_number"):
|
|
631
|
+
body["phoneNumber"] = data["phone_number"]
|
|
632
|
+
if data.get("policy"):
|
|
633
|
+
body["callPolicies"] = {"policy": data["policy"]}
|
|
634
|
+
if data.get("no_answer_rings"):
|
|
635
|
+
body["callPolicies"]["noAnswer"] = {
|
|
636
|
+
"numberOfRings": data["no_answer_rings"]
|
|
637
|
+
}
|
|
638
|
+
if agents:
|
|
639
|
+
body["agents"] = agents
|
|
640
|
+
return [("POST", _url(f"/telephony/config/locations/{loc_wid}/huntGroups", ctx), body)]
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def handle_call_queue_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
644
|
+
loc_wid = _resolve_location(data, deps)
|
|
645
|
+
agents, skipped_agents = _resolve_agents(data, deps, "agents")
|
|
646
|
+
# Per spec resolved decision #3: call queues proceed with partial
|
|
647
|
+
# membership when some agents fail to resolve. Log a WARNING for each
|
|
648
|
+
# so the operator can reconcile post-migration.
|
|
649
|
+
# Wave 3 will add full op-metadata recording; today this is log-only.
|
|
650
|
+
if skipped_agents:
|
|
651
|
+
cq_name = data.get("name", "<unnamed>")
|
|
652
|
+
for cid in skipped_agents:
|
|
653
|
+
logger.warning(
|
|
654
|
+
"call_queue '%s': agent %s not resolved — excluded from create "
|
|
655
|
+
"(add manually post-migration)",
|
|
656
|
+
cq_name, cid,
|
|
657
|
+
)
|
|
658
|
+
body: dict[str, Any] = {
|
|
659
|
+
"name": data.get("name"),
|
|
660
|
+
"extension": data.get("extension"),
|
|
661
|
+
"callPolicies": {
|
|
662
|
+
"routingType": data.get("routing_type", "PRIORITY_BASED"),
|
|
663
|
+
"policy": data.get("policy", "CIRCULAR"),
|
|
664
|
+
},
|
|
665
|
+
}
|
|
666
|
+
if data.get("phone_number"):
|
|
667
|
+
body["phoneNumber"] = data["phone_number"]
|
|
668
|
+
if agents:
|
|
669
|
+
body["agents"] = agents
|
|
670
|
+
if data.get("queue_size"):
|
|
671
|
+
body["queueSize"] = data["queue_size"]
|
|
672
|
+
return [("POST", _url(f"/telephony/config/locations/{loc_wid}/queues", ctx), body)]
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _sanitize_aa_menu(menu: dict) -> dict:
|
|
676
|
+
"""Remove value field from TRANSFER_TO_OPERATOR keys (value is only valid
|
|
677
|
+
for TRANSFER_TO_EXTENSION / TRANSFER_TO_NUMBER actions).
|
|
678
|
+
Also remove value from TRANSFER_TO_OPERATOR to avoid 4806 phone validation.
|
|
679
|
+
"""
|
|
680
|
+
if not menu:
|
|
681
|
+
return menu
|
|
682
|
+
cleaned = dict(menu)
|
|
683
|
+
key_configs = cleaned.get("keyConfigurations", [])
|
|
684
|
+
sanitized = []
|
|
685
|
+
for kc in key_configs:
|
|
686
|
+
kc = dict(kc)
|
|
687
|
+
if kc.get("action") == "TRANSFER_TO_OPERATOR":
|
|
688
|
+
kc.pop("value", None)
|
|
689
|
+
sanitized.append(kc)
|
|
690
|
+
cleaned["keyConfigurations"] = sanitized
|
|
691
|
+
return cleaned
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def handle_auto_attendant_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
695
|
+
loc_wid = _resolve_location(data, deps) or _resolve_location_from_deps(deps)
|
|
696
|
+
body: dict[str, Any] = {
|
|
697
|
+
"name": data.get("name"),
|
|
698
|
+
}
|
|
699
|
+
if data.get("extension"):
|
|
700
|
+
body["extension"] = data["extension"]
|
|
701
|
+
if data.get("phone_number"):
|
|
702
|
+
body["phoneNumber"] = data["phone_number"]
|
|
703
|
+
# API requires at least one of extension or phoneNumber
|
|
704
|
+
if "extension" not in body and "phoneNumber" not in body:
|
|
705
|
+
return [] # No-op: no reachable number, skip AA creation
|
|
706
|
+
if data.get("business_schedule"):
|
|
707
|
+
body["businessSchedule"] = data["business_schedule"]
|
|
708
|
+
if data.get("business_hours_menu"):
|
|
709
|
+
body["businessHoursMenu"] = _sanitize_aa_menu(data["business_hours_menu"])
|
|
710
|
+
if data.get("after_hours_menu"):
|
|
711
|
+
body["afterHoursMenu"] = _sanitize_aa_menu(data["after_hours_menu"])
|
|
712
|
+
return [("POST", _url(f"/telephony/config/locations/{loc_wid}/autoAttendants", ctx), body)]
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def handle_call_park_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
716
|
+
loc_wid = _resolve_location(data, deps) or _resolve_location_from_deps(deps)
|
|
717
|
+
body: dict[str, Any] = {
|
|
718
|
+
"name": data.get("name"),
|
|
719
|
+
"extension": data.get("extension"),
|
|
720
|
+
"recall": {"option": "ALERT_PARKING_USER_ONLY"},
|
|
721
|
+
}
|
|
722
|
+
return [("POST", _url(f"/telephony/config/locations/{loc_wid}/callParks", ctx), body)]
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def handle_pickup_group_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
726
|
+
loc_wid = _resolve_location(data, deps) or _resolve_location_from_deps(deps)
|
|
727
|
+
# Call Pickup API takes agents as plain string array, not [{"id": ...}]
|
|
728
|
+
agent_ids = _resolve_agent_ids(data, deps, "agents")
|
|
729
|
+
body: dict[str, Any] = {"name": data.get("name")}
|
|
730
|
+
if agent_ids:
|
|
731
|
+
body["agents"] = agent_ids
|
|
732
|
+
return [("POST", _url(f"/telephony/config/locations/{loc_wid}/callPickups", ctx), body)]
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def handle_paging_group_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
736
|
+
# CanonicalPagingGroup has no location_id field — resolve from deps
|
|
737
|
+
loc_wid = _resolve_location(data, deps) or _resolve_location_from_deps(deps)
|
|
738
|
+
body: dict[str, Any] = {
|
|
739
|
+
"name": data.get("name"),
|
|
740
|
+
"extension": data.get("extension"),
|
|
741
|
+
}
|
|
742
|
+
# Paging Group API takes targets/originators as plain string arrays, not [{"id": ...}]
|
|
743
|
+
target_ids = _resolve_agent_ids(data, deps, "targets")
|
|
744
|
+
if target_ids:
|
|
745
|
+
body["targets"] = target_ids
|
|
746
|
+
originator_ids = _resolve_agent_ids(data, deps, "originators")
|
|
747
|
+
if originator_ids:
|
|
748
|
+
body["originators"] = originator_ids
|
|
749
|
+
body["originatorCallerIdEnabled"] = True
|
|
750
|
+
return [("POST", _url(f"/telephony/config/locations/{loc_wid}/paging", ctx), body)]
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def handle_voicemail_group_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
754
|
+
"""Create a Webex Voicemail Group at a location.
|
|
755
|
+
|
|
756
|
+
(from docs/superpowers/specs/2026-04-10-voicemail-groups.md)
|
|
757
|
+
POST /v1/telephony/config/locations/{locationId}/voicemailGroups
|
|
758
|
+
"""
|
|
759
|
+
loc_wid = _resolve_location(data, deps) or _resolve_location_from_deps(deps)
|
|
760
|
+
if not loc_wid:
|
|
761
|
+
return skipped(
|
|
762
|
+
f"location not resolved for voicemail_group "
|
|
763
|
+
f"{data.get('name')!r}"
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
if not data.get("extension"):
|
|
767
|
+
return [] # true no-op: no extension configured, cannot create VM group
|
|
768
|
+
|
|
769
|
+
body: dict[str, Any] = {
|
|
770
|
+
"name": data.get("name"),
|
|
771
|
+
"extension": data.get("extension"),
|
|
772
|
+
"passcode": data.get("passcode"),
|
|
773
|
+
"languageCode": data.get("language_code", "en_us"),
|
|
774
|
+
"messageStorage": data.get(
|
|
775
|
+
"message_storage", {"storageType": "INTERNAL"}
|
|
776
|
+
),
|
|
777
|
+
"notifications": data.get("notifications", {"enabled": False}),
|
|
778
|
+
"faxMessage": data.get("fax_message", {"enabled": False}),
|
|
779
|
+
"transferToNumber": data.get(
|
|
780
|
+
"transfer_to_number", {"enabled": False}
|
|
781
|
+
),
|
|
782
|
+
"emailCopyOfMessage": data.get(
|
|
783
|
+
"email_copy_of_message", {"enabled": False}
|
|
784
|
+
),
|
|
785
|
+
}
|
|
786
|
+
if data.get("phone_number"):
|
|
787
|
+
body["phoneNumber"] = data["phone_number"]
|
|
788
|
+
caller_id_name = data.get("caller_id_name")
|
|
789
|
+
if caller_id_name:
|
|
790
|
+
body["directLineCallerIdName"] = {
|
|
791
|
+
"selection": "CUSTOM_NAME",
|
|
792
|
+
"customName": caller_id_name,
|
|
793
|
+
}
|
|
794
|
+
body["dialByName"] = caller_id_name
|
|
795
|
+
|
|
796
|
+
url = _url(
|
|
797
|
+
f"/telephony/config/locations/{loc_wid}/voicemailGroups", ctx
|
|
798
|
+
)
|
|
799
|
+
return [("POST", url, body)]
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
# ---------------------------------------------------------------------------
|
|
803
|
+
# Tier 5: Settings configuration
|
|
804
|
+
# ---------------------------------------------------------------------------
|
|
805
|
+
|
|
806
|
+
# Features that require read-before-write: the API rejects partial bodies
|
|
807
|
+
# because it proxies to a backend that needs all fields present.
|
|
808
|
+
# For these features, the handler emits GET (read current) then PUT (merge + overwrite).
|
|
809
|
+
_READ_BEFORE_WRITE_FEATURES = {"callRecording"}
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def handle_user_configure_settings(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
813
|
+
# Resolve the target user by this op's own canonical_id rather than
|
|
814
|
+
# scanning deps for the first "user:" prefix. In dep graphs where more
|
|
815
|
+
# than one user is resolved (e.g., monitoring, shared lines), the scan
|
|
816
|
+
# could pick the wrong user and silently rewrite settings on them —
|
|
817
|
+
# especially risky now that callRecording does GET + merge + PUT.
|
|
818
|
+
user_cid = data.get("canonical_id")
|
|
819
|
+
person_wid = deps.get(user_cid) if user_cid else None
|
|
820
|
+
if not person_wid:
|
|
821
|
+
return skipped(
|
|
822
|
+
f"user not resolved for configure_settings "
|
|
823
|
+
f"(canonical_id={user_cid!r})"
|
|
824
|
+
)
|
|
825
|
+
settings = data.get("call_settings", {})
|
|
826
|
+
calls = []
|
|
827
|
+
for feature_name, feature_body in settings.items():
|
|
828
|
+
url = _url(f"/people/{person_wid}/features/{feature_name}", ctx)
|
|
829
|
+
if feature_name in _READ_BEFORE_WRITE_FEATURES:
|
|
830
|
+
# Read current state first, then merge our overrides on top
|
|
831
|
+
calls.append(("GET", url, None))
|
|
832
|
+
calls.append(("PUT", url, {**feature_body, "_merge_from_previous": True}))
|
|
833
|
+
else:
|
|
834
|
+
calls.append(("PUT", url, feature_body))
|
|
835
|
+
return calls
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def handle_user_configure_voicemail(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
839
|
+
# Same rationale as handle_user_configure_settings — resolve target
|
|
840
|
+
# user by this op's canonical_id, not by scanning deps. Writing to the
|
|
841
|
+
# wrong user's voicemail settings would be silently destructive.
|
|
842
|
+
user_cid = data.get("canonical_id")
|
|
843
|
+
person_wid = deps.get(user_cid) if user_cid else None
|
|
844
|
+
if not person_wid:
|
|
845
|
+
return skipped(
|
|
846
|
+
f"user not resolved for configure_voicemail "
|
|
847
|
+
f"(canonical_id={user_cid!r})"
|
|
848
|
+
)
|
|
849
|
+
vm_data = data.get("voicemail") or data.get("voicemail_settings") or {}
|
|
850
|
+
return [("PUT", _url(f"/telephony/config/people/{person_wid}/voicemail", ctx), vm_data)]
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def handle_ecbn_config_configure(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
854
|
+
"""Configure ECBN for a user, workspace, or virtual line.
|
|
855
|
+
|
|
856
|
+
(from 2026-04-10-e911-ecbn-execution.md §4.4)
|
|
857
|
+
"""
|
|
858
|
+
entity_type = data.get("entity_type", "")
|
|
859
|
+
entity_cid = data.get("entity_canonical_id", "")
|
|
860
|
+
entity_wid = deps.get(entity_cid)
|
|
861
|
+
if not entity_wid:
|
|
862
|
+
return skipped(f"ECBN target {entity_cid!r} ({entity_type}) not resolved")
|
|
863
|
+
|
|
864
|
+
path_map = {
|
|
865
|
+
"user": "people",
|
|
866
|
+
"workspace": "workspaces",
|
|
867
|
+
"virtual_line": "virtualLines",
|
|
868
|
+
}
|
|
869
|
+
path_segment = path_map.get(entity_type)
|
|
870
|
+
if not path_segment:
|
|
871
|
+
return skipped(f"ECBN entity_type {entity_type!r} not supported")
|
|
872
|
+
|
|
873
|
+
selection = data.get("ecbn_selection", "")
|
|
874
|
+
body: dict[str, Any] = {"selected": selection}
|
|
875
|
+
if selection == "LOCATION_MEMBER_NUMBER":
|
|
876
|
+
member_cid = data.get("location_member_canonical_id", "")
|
|
877
|
+
member_wid = deps.get(member_cid)
|
|
878
|
+
if not member_wid:
|
|
879
|
+
return skipped(f"ECBN location_member {member_cid!r} not resolved")
|
|
880
|
+
body["locationMemberId"] = member_wid
|
|
881
|
+
|
|
882
|
+
url = _url(
|
|
883
|
+
f"/telephony/config/{path_segment}/{entity_wid}/emergencyCallbackNumber",
|
|
884
|
+
ctx,
|
|
885
|
+
)
|
|
886
|
+
return [("PUT", url, body)]
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def handle_device_configure_settings(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
890
|
+
# Resolve target device by this op's own canonical_id rather than
|
|
891
|
+
# scanning deps for the first "device:" prefix (same class of bug as
|
|
892
|
+
# handle_user_configure_settings: wrong device → silent mis-config).
|
|
893
|
+
device_cid = data.get("canonical_id")
|
|
894
|
+
device_wid = deps.get(device_cid) if device_cid else None
|
|
895
|
+
if not device_wid:
|
|
896
|
+
return skipped("device:configure_settings — device not resolved from deps")
|
|
897
|
+
settings = data.get("device_settings", {})
|
|
898
|
+
if not settings:
|
|
899
|
+
return []
|
|
900
|
+
return [("PUT", _url(f"/telephony/config/devices/{device_wid}/settings", ctx), settings)]
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def handle_workspace_configure_settings(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
904
|
+
# Resolve target workspace by this op's own canonical_id (see
|
|
905
|
+
# handle_user_configure_settings for full rationale).
|
|
906
|
+
ws_cid = data.get("canonical_id")
|
|
907
|
+
ws_wid = deps.get(ws_cid) if ws_cid else None
|
|
908
|
+
if not ws_wid:
|
|
909
|
+
return skipped("workspace:configure_settings — workspace not resolved from deps")
|
|
910
|
+
# Workspace settings use /telephony/config/workspaces/{id}/ path family
|
|
911
|
+
# (NOT /workspaces/{id}/features/ — that's a different API surface)
|
|
912
|
+
settings = data.get("call_settings", {})
|
|
913
|
+
calls = []
|
|
914
|
+
for feature_name, feature_body in settings.items():
|
|
915
|
+
url = _url(f"/telephony/config/workspaces/{ws_wid}/{feature_name}", ctx)
|
|
916
|
+
calls.append(("PUT", url, feature_body))
|
|
917
|
+
return calls
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def handle_calling_permission_assign(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
921
|
+
permissions = data.get("calling_permissions", [])
|
|
922
|
+
api_permissions = [
|
|
923
|
+
{"callType": p["call_type"], "action": p.get("action", "ALLOW")}
|
|
924
|
+
for p in permissions
|
|
925
|
+
]
|
|
926
|
+
body: dict[str, Any] = {
|
|
927
|
+
"useCustomEnabled": data.get("use_custom_enabled", True),
|
|
928
|
+
"callingPermissions": api_permissions,
|
|
929
|
+
}
|
|
930
|
+
calls = []
|
|
931
|
+
for user_cid in data.get("assigned_users", []):
|
|
932
|
+
person_wid = deps.get(user_cid)
|
|
933
|
+
if person_wid:
|
|
934
|
+
url = _url(f"/people/{person_wid}/features/outgoingPermission", ctx)
|
|
935
|
+
calls.append(("PUT", url, body))
|
|
936
|
+
return calls
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
# ---------------------------------------------------------------------------
|
|
940
|
+
# Tier 5: Call forwarding
|
|
941
|
+
# ---------------------------------------------------------------------------
|
|
942
|
+
|
|
943
|
+
def handle_call_forwarding_configure(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
944
|
+
user_cid = data.get("user_canonical_id", "")
|
|
945
|
+
person_wid = deps.get(user_cid)
|
|
946
|
+
if not person_wid:
|
|
947
|
+
return skipped(f"call_forwarding: user {user_cid!r} not resolved")
|
|
948
|
+
# Only generate if at least one forwarding type is enabled
|
|
949
|
+
if not any([data.get("always_enabled"), data.get("busy_enabled"), data.get("no_answer_enabled")]):
|
|
950
|
+
return []
|
|
951
|
+
def _cf_block(**kwargs: Any) -> dict[str, Any]:
|
|
952
|
+
"""Build a callForwarding sub-block, omitting None-valued optional fields."""
|
|
953
|
+
return {k: v for k, v in kwargs.items() if v is not None}
|
|
954
|
+
|
|
955
|
+
body: dict[str, Any] = {
|
|
956
|
+
"callForwarding": {
|
|
957
|
+
"always": _cf_block(
|
|
958
|
+
enabled=bool(data.get("always_enabled")),
|
|
959
|
+
destination=data.get("always_destination"),
|
|
960
|
+
ringReminderEnabled=False,
|
|
961
|
+
destinationVoicemailEnabled=bool(data.get("always_to_voicemail")),
|
|
962
|
+
),
|
|
963
|
+
"busy": _cf_block(
|
|
964
|
+
enabled=bool(data.get("busy_enabled")),
|
|
965
|
+
destination=data.get("busy_destination"),
|
|
966
|
+
destinationVoicemailEnabled=bool(data.get("busy_to_voicemail")),
|
|
967
|
+
),
|
|
968
|
+
"noAnswer": _cf_block(
|
|
969
|
+
enabled=bool(data.get("no_answer_enabled")),
|
|
970
|
+
destination=data.get("no_answer_destination"),
|
|
971
|
+
destinationVoicemailEnabled=bool(data.get("no_answer_to_voicemail")),
|
|
972
|
+
numberOfRings=data.get("no_answer_ring_count"),
|
|
973
|
+
),
|
|
974
|
+
},
|
|
975
|
+
"businessContinuity": {"enabled": False},
|
|
976
|
+
}
|
|
977
|
+
return [("PUT", _url(f"/people/{person_wid}/features/callForwarding", ctx), body)]
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
# ---------------------------------------------------------------------------
|
|
981
|
+
# Tier 5: Single Number Reach
|
|
982
|
+
# ---------------------------------------------------------------------------
|
|
983
|
+
|
|
984
|
+
def handle_snr_configure(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
985
|
+
"""Configure Single Number Reach for a person.
|
|
986
|
+
|
|
987
|
+
Produces 1 PUT (enable SNR) + 1 POST per number.
|
|
988
|
+
(from tier2-enterprise-expansion.md §4)
|
|
989
|
+
"""
|
|
990
|
+
user_cid = data.get("user_canonical_id", "")
|
|
991
|
+
person_wid = deps.get(user_cid)
|
|
992
|
+
if not person_wid:
|
|
993
|
+
return skipped(f"single_number_reach: user {user_cid!r} not resolved")
|
|
994
|
+
numbers = data.get("numbers") or []
|
|
995
|
+
if not numbers:
|
|
996
|
+
return []
|
|
997
|
+
|
|
998
|
+
calls: list[tuple[str, str, dict | None]] = []
|
|
999
|
+
|
|
1000
|
+
# Call 1: Enable SNR at the top level
|
|
1001
|
+
enable_body = {
|
|
1002
|
+
"enabled": bool(data.get("enabled", True)),
|
|
1003
|
+
"alertAllNumbersForClickToDialCallsEnabled": bool(data.get("alert_click_to_dial", False)),
|
|
1004
|
+
}
|
|
1005
|
+
calls.append((
|
|
1006
|
+
"PUT",
|
|
1007
|
+
_url(f"/telephony/config/people/{person_wid}/singleNumberReach", ctx),
|
|
1008
|
+
enable_body,
|
|
1009
|
+
))
|
|
1010
|
+
|
|
1011
|
+
# Call 2+: Add each number
|
|
1012
|
+
for num in numbers:
|
|
1013
|
+
phone_number = num.get("phone_number") or ""
|
|
1014
|
+
if not phone_number:
|
|
1015
|
+
continue
|
|
1016
|
+
num_body = {
|
|
1017
|
+
"phoneNumber": phone_number,
|
|
1018
|
+
"enabled": bool(num.get("enabled", True)),
|
|
1019
|
+
"answerConfirmationEnabled": bool(num.get("answer_confirmation", False)),
|
|
1020
|
+
}
|
|
1021
|
+
name = num.get("name")
|
|
1022
|
+
if name:
|
|
1023
|
+
num_body["name"] = name
|
|
1024
|
+
calls.append((
|
|
1025
|
+
"POST",
|
|
1026
|
+
_url(f"/telephony/config/people/{person_wid}/singleNumberReach/numbers", ctx),
|
|
1027
|
+
num_body,
|
|
1028
|
+
))
|
|
1029
|
+
|
|
1030
|
+
return calls
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
# ---------------------------------------------------------------------------
|
|
1034
|
+
# Tier 6: Monitoring list
|
|
1035
|
+
# ---------------------------------------------------------------------------
|
|
1036
|
+
|
|
1037
|
+
def handle_monitoring_list_configure(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1038
|
+
user_cid = data.get("user_canonical_id", "")
|
|
1039
|
+
person_wid = deps.get(user_cid)
|
|
1040
|
+
if not person_wid:
|
|
1041
|
+
return skipped(f"monitoring_list: user {user_cid!r} not resolved")
|
|
1042
|
+
members: list[dict[str, Any]] = []
|
|
1043
|
+
unresolved: list[str] = []
|
|
1044
|
+
total_count = 0
|
|
1045
|
+
for m in data.get("monitored_members", []):
|
|
1046
|
+
target_cid = m.get("target_canonical_id")
|
|
1047
|
+
if not target_cid:
|
|
1048
|
+
continue
|
|
1049
|
+
total_count += 1
|
|
1050
|
+
wid = deps.get(target_cid)
|
|
1051
|
+
if wid:
|
|
1052
|
+
members.append({"id": wid})
|
|
1053
|
+
else:
|
|
1054
|
+
unresolved.append(target_cid)
|
|
1055
|
+
if total_count and not members:
|
|
1056
|
+
list_name = data.get("name") or user_cid
|
|
1057
|
+
return skipped(
|
|
1058
|
+
f"monitoring list {list_name!r}: all {total_count} members unresolved",
|
|
1059
|
+
)
|
|
1060
|
+
if unresolved:
|
|
1061
|
+
logger.warning(
|
|
1062
|
+
"Monitoring list for %s: %d of %d members unresolved: %s",
|
|
1063
|
+
person_wid,
|
|
1064
|
+
len(unresolved),
|
|
1065
|
+
total_count,
|
|
1066
|
+
unresolved,
|
|
1067
|
+
)
|
|
1068
|
+
if not members:
|
|
1069
|
+
return []
|
|
1070
|
+
body: dict[str, Any] = {
|
|
1071
|
+
"enableCallParkNotification": bool(data.get("call_park_notification_enabled")),
|
|
1072
|
+
"monitoredMembers": members,
|
|
1073
|
+
}
|
|
1074
|
+
return [("PUT", _url(f"/people/{person_wid}/features/monitoring", ctx), body)]
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
# ---------------------------------------------------------------------------
|
|
1078
|
+
# Tier 6: Receptionist configuration
|
|
1079
|
+
# ---------------------------------------------------------------------------
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def handle_receptionist_config_configure(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1083
|
+
"""Enable receptionist client + create location contact directory."""
|
|
1084
|
+
user_cid = data.get("user_canonical_id", "")
|
|
1085
|
+
person_wid = deps.get(user_cid)
|
|
1086
|
+
if not person_wid:
|
|
1087
|
+
return skipped(f"receptionist_config: user {user_cid!r} not resolved")
|
|
1088
|
+
members = []
|
|
1089
|
+
for cid in data.get("monitored_members", []):
|
|
1090
|
+
wid = deps.get(cid)
|
|
1091
|
+
if wid:
|
|
1092
|
+
members.append(wid)
|
|
1093
|
+
reception_body: dict[str, Any] = {
|
|
1094
|
+
"receptionEnabled": True,
|
|
1095
|
+
"monitoredMembers": members,
|
|
1096
|
+
}
|
|
1097
|
+
calls: HandlerResult = [
|
|
1098
|
+
("PUT", _url(f"/people/{person_wid}/features/reception", ctx), reception_body),
|
|
1099
|
+
]
|
|
1100
|
+
loc_cid = data.get("location_canonical_id", "")
|
|
1101
|
+
loc_wid = deps.get(loc_cid) if loc_cid else None
|
|
1102
|
+
if loc_wid and members:
|
|
1103
|
+
contacts = [{"personId": wid} for wid in members]
|
|
1104
|
+
dir_body: dict[str, Any] = {
|
|
1105
|
+
"name": "Reception Directory",
|
|
1106
|
+
"contacts": contacts,
|
|
1107
|
+
}
|
|
1108
|
+
calls.append((
|
|
1109
|
+
"POST",
|
|
1110
|
+
_url(f"/telephony/config/locations/{loc_wid}/receptionistContacts/directories", ctx),
|
|
1111
|
+
dir_body,
|
|
1112
|
+
))
|
|
1113
|
+
return calls
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
# ---------------------------------------------------------------------------
|
|
1117
|
+
# Tier 7: Device finalization (after monitoring, shared lines)
|
|
1118
|
+
# ---------------------------------------------------------------------------
|
|
1119
|
+
|
|
1120
|
+
def handle_device_layout_configure(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1121
|
+
device_cid = data.get("device_canonical_id")
|
|
1122
|
+
device_wid = deps.get(device_cid) if device_cid else None
|
|
1123
|
+
if not device_wid:
|
|
1124
|
+
return skipped(f"device_layout: device {device_cid!r} not resolved")
|
|
1125
|
+
|
|
1126
|
+
results: HandlerResult = []
|
|
1127
|
+
|
|
1128
|
+
# Call 1 (conditional): PUT members
|
|
1129
|
+
resolved_members = []
|
|
1130
|
+
for m in data.get("line_members", []):
|
|
1131
|
+
member_cid = m.get("member_canonical_id")
|
|
1132
|
+
wid = deps.get(member_cid) if member_cid else None
|
|
1133
|
+
if wid:
|
|
1134
|
+
resolved_members.append({"id": wid, "port": m.get("port", 1)})
|
|
1135
|
+
if resolved_members:
|
|
1136
|
+
results.append((
|
|
1137
|
+
"PUT",
|
|
1138
|
+
_url(f"/telephony/config/devices/{device_wid}/members", ctx),
|
|
1139
|
+
{"members": resolved_members},
|
|
1140
|
+
))
|
|
1141
|
+
|
|
1142
|
+
# Call 2: PUT layout
|
|
1143
|
+
def _map_lk(k: dict) -> dict:
|
|
1144
|
+
entry: dict[str, Any] = {"lineKeyIndex": k["index"], "lineKeyType": k["key_type"]}
|
|
1145
|
+
if k.get("label"):
|
|
1146
|
+
entry["lineKeyLabel"] = k["label"]
|
|
1147
|
+
if k.get("value"):
|
|
1148
|
+
entry["lineKeyValue"] = k["value"]
|
|
1149
|
+
return entry
|
|
1150
|
+
|
|
1151
|
+
def _map_kk(k: dict) -> dict:
|
|
1152
|
+
entry: dict[str, Any] = {
|
|
1153
|
+
"kemModuleIndex": k.get("module_index", 1),
|
|
1154
|
+
"kemKeyIndex": k["index"],
|
|
1155
|
+
"kemKeyType": k["key_type"],
|
|
1156
|
+
}
|
|
1157
|
+
if k.get("label"):
|
|
1158
|
+
entry["kemKeyLabel"] = k["label"]
|
|
1159
|
+
return entry
|
|
1160
|
+
|
|
1161
|
+
raw_line_keys = data.get("resolved_line_keys", [])
|
|
1162
|
+
raw_kem_keys = data.get("resolved_kem_keys", [])
|
|
1163
|
+
# Filter line keys for Webex layout API:
|
|
1164
|
+
# - Skip PRIMARY_LINE (auto-assigned by Webex at index 1)
|
|
1165
|
+
# - Skip keys that require a value but have none (MONITOR, BLF, CALL_PARK_EXTENSION)
|
|
1166
|
+
# - Skip OPEN keys (no-op placeholders)
|
|
1167
|
+
# - Keep only SPEED_DIAL with a value
|
|
1168
|
+
_SKIP_TYPES = {"PRIMARY_LINE", "OPEN"}
|
|
1169
|
+
_VALUE_REQUIRED_TYPES = {"SPEED_DIAL", "MONITOR", "CALL_PARK_EXTENSION", "BLF"}
|
|
1170
|
+
line_keys = []
|
|
1171
|
+
idx = 2 # Start at 2 — index 1 is the primary line (auto-assigned)
|
|
1172
|
+
for k in raw_line_keys:
|
|
1173
|
+
kt = k.get("key_type", "OPEN")
|
|
1174
|
+
if kt in _SKIP_TYPES:
|
|
1175
|
+
continue
|
|
1176
|
+
if kt in _VALUE_REQUIRED_TYPES and not k.get("value"):
|
|
1177
|
+
continue
|
|
1178
|
+
line_keys.append({**k, "index": idx, "key_type": kt})
|
|
1179
|
+
idx += 1
|
|
1180
|
+
# Skip KEM keys if device model doesn't support KEM (7841, 78xx without KEM)
|
|
1181
|
+
kem_keys = raw_kem_keys if data.get("kem_supported") else []
|
|
1182
|
+
layout_mode = "CUSTOM" if line_keys else "DEFAULT"
|
|
1183
|
+
layout_body: dict[str, Any] = {"layoutMode": layout_mode}
|
|
1184
|
+
if line_keys:
|
|
1185
|
+
layout_body["lineKeys"] = [_map_lk(k) for k in line_keys]
|
|
1186
|
+
if kem_keys:
|
|
1187
|
+
layout_body["kemKeys"] = [_map_kk(k) for k in kem_keys]
|
|
1188
|
+
results.append((
|
|
1189
|
+
"PUT",
|
|
1190
|
+
_url(f"/telephony/config/devices/{device_wid}/layout", ctx),
|
|
1191
|
+
layout_body,
|
|
1192
|
+
))
|
|
1193
|
+
|
|
1194
|
+
# Call 3: POST applyChanges
|
|
1195
|
+
results.append((
|
|
1196
|
+
"POST",
|
|
1197
|
+
_url(f"/telephony/config/devices/{device_wid}/actions/applyChanges/invoke", ctx),
|
|
1198
|
+
None,
|
|
1199
|
+
))
|
|
1200
|
+
return results
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def handle_softkey_config_configure(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1204
|
+
if not data.get("is_psk_target"):
|
|
1205
|
+
return []
|
|
1206
|
+
device_cid = data.get("device_canonical_id")
|
|
1207
|
+
device_wid = deps.get(device_cid) if device_cid else None
|
|
1208
|
+
if not device_wid:
|
|
1209
|
+
return skipped(f"softkey_config: device {device_cid!r} not resolved")
|
|
1210
|
+
|
|
1211
|
+
settings = []
|
|
1212
|
+
for m in data.get("psk_mappings", []):
|
|
1213
|
+
slot = m.get("psk_slot", "").lower() # "PSK1" -> "psk1"
|
|
1214
|
+
keyword = m.get("keyword", "")
|
|
1215
|
+
if slot and keyword:
|
|
1216
|
+
settings.append({"key": f"softKeyLayout.psk.{slot}", "value": keyword})
|
|
1217
|
+
for state, keywords in (data.get("state_key_lists") or {}).items():
|
|
1218
|
+
value = ";".join(keywords) if keywords else ""
|
|
1219
|
+
if value:
|
|
1220
|
+
settings.append({
|
|
1221
|
+
"key": f"softKeyLayout.softKeyMenu.{state}KeyList",
|
|
1222
|
+
"value": value,
|
|
1223
|
+
})
|
|
1224
|
+
if not settings:
|
|
1225
|
+
return []
|
|
1226
|
+
|
|
1227
|
+
return [
|
|
1228
|
+
(
|
|
1229
|
+
"PUT",
|
|
1230
|
+
_url(f"/telephony/config/devices/{device_wid}/dynamicSettings", ctx),
|
|
1231
|
+
{"settings": settings},
|
|
1232
|
+
),
|
|
1233
|
+
(
|
|
1234
|
+
"POST",
|
|
1235
|
+
_url(f"/telephony/config/devices/{device_wid}/actions/applyChanges/invoke", ctx),
|
|
1236
|
+
None,
|
|
1237
|
+
),
|
|
1238
|
+
]
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
# ---------------------------------------------------------------------------
|
|
1242
|
+
# Tier 6: Shared/virtual lines
|
|
1243
|
+
# ---------------------------------------------------------------------------
|
|
1244
|
+
|
|
1245
|
+
def handle_shared_line_configure(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1246
|
+
owner_cids = data.get("owner_canonical_ids", [])
|
|
1247
|
+
|
|
1248
|
+
# Resolve all owner Webex IDs
|
|
1249
|
+
resolved_owners = []
|
|
1250
|
+
for cid in owner_cids:
|
|
1251
|
+
wid = deps.get(cid)
|
|
1252
|
+
if wid:
|
|
1253
|
+
resolved_owners.append((cid, wid))
|
|
1254
|
+
|
|
1255
|
+
if len(resolved_owners) < 2:
|
|
1256
|
+
# Need at least 2 owners for a shared line (issue #17).
|
|
1257
|
+
return skipped(
|
|
1258
|
+
f"shared line requires 2+ resolved owners; "
|
|
1259
|
+
f"only {len(resolved_owners)} of {len(owner_cids)} resolved",
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
calls = []
|
|
1263
|
+
|
|
1264
|
+
for owner_cid, owner_wid in resolved_owners:
|
|
1265
|
+
# Build members list: all OTHER resolved owners
|
|
1266
|
+
members = []
|
|
1267
|
+
port = 1
|
|
1268
|
+
for other_idx, (other_cid, other_wid) in enumerate(resolved_owners):
|
|
1269
|
+
if other_cid == owner_cid:
|
|
1270
|
+
continue
|
|
1271
|
+
is_primary = (other_idx == 0) # First owner in list is primary
|
|
1272
|
+
members.append({
|
|
1273
|
+
"memberId": other_wid,
|
|
1274
|
+
"port": port,
|
|
1275
|
+
"primaryOwner": is_primary,
|
|
1276
|
+
"lineType": "SHARED_CALL_APPEARANCE",
|
|
1277
|
+
"lineWeight": port,
|
|
1278
|
+
})
|
|
1279
|
+
port += 1
|
|
1280
|
+
|
|
1281
|
+
if members:
|
|
1282
|
+
calls.append((
|
|
1283
|
+
"PUT",
|
|
1284
|
+
_url(f"/telephony/config/people/{owner_wid}/applications/members", ctx),
|
|
1285
|
+
{"members": members},
|
|
1286
|
+
))
|
|
1287
|
+
|
|
1288
|
+
return calls
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def handle_virtual_line_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1292
|
+
loc_wid = _resolve_location(data, deps)
|
|
1293
|
+
body: dict[str, Any] = {
|
|
1294
|
+
"firstName": data.get("display_name", "Virtual"),
|
|
1295
|
+
"lastName": "Line",
|
|
1296
|
+
"locationId": loc_wid,
|
|
1297
|
+
}
|
|
1298
|
+
if data.get("extension"):
|
|
1299
|
+
body["extension"] = data["extension"]
|
|
1300
|
+
if data.get("phone_number"):
|
|
1301
|
+
body["phoneNumber"] = data["phone_number"]
|
|
1302
|
+
return [("POST", _url("/telephony/config/virtualLines", ctx), body)]
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def handle_virtual_line_configure(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1306
|
+
# Resolve target virtual line by this op's own canonical_id (see
|
|
1307
|
+
# handle_user_configure_settings for full rationale).
|
|
1308
|
+
vl_cid = data.get("canonical_id")
|
|
1309
|
+
vl_wid = deps.get(vl_cid) if vl_cid else None
|
|
1310
|
+
if not vl_wid:
|
|
1311
|
+
return skipped(
|
|
1312
|
+
f"virtual_line:configure — virtual line "
|
|
1313
|
+
f"{data.get('display_name') or data.get('canonical_id')!r} not resolved from deps",
|
|
1314
|
+
)
|
|
1315
|
+
# Virtual line settings — pass through whatever canonical data has
|
|
1316
|
+
settings = data.get("settings", {})
|
|
1317
|
+
if not settings:
|
|
1318
|
+
return []
|
|
1319
|
+
return [("PUT", _url(f"/telephony/config/virtualLines/{vl_wid}", ctx), settings)]
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
# ---------------------------------------------------------------------------
|
|
1323
|
+
# Tier 1/5: Device settings templates
|
|
1324
|
+
# ---------------------------------------------------------------------------
|
|
1325
|
+
|
|
1326
|
+
def handle_device_settings_template_apply_location_settings(
|
|
1327
|
+
data: dict, deps: dict, ctx: dict,
|
|
1328
|
+
) -> HandlerResult:
|
|
1329
|
+
"""Apply device settings at location level."""
|
|
1330
|
+
loc_cid = data.get("location_canonical_id")
|
|
1331
|
+
if not loc_cid:
|
|
1332
|
+
return skipped(
|
|
1333
|
+
"device_settings_template:apply_location_settings — "
|
|
1334
|
+
"location_canonical_id missing from template data",
|
|
1335
|
+
)
|
|
1336
|
+
loc_wid = deps.get(loc_cid)
|
|
1337
|
+
if not loc_wid:
|
|
1338
|
+
return skipped(
|
|
1339
|
+
f"device_settings_template:apply_location_settings — "
|
|
1340
|
+
f"location {loc_cid!r} not resolved",
|
|
1341
|
+
)
|
|
1342
|
+
settings = data.get("settings") or {}
|
|
1343
|
+
if not settings:
|
|
1344
|
+
return []
|
|
1345
|
+
body = {"customizations": {"mpp": settings}, "customEnabled": True}
|
|
1346
|
+
return [("PUT", _url(f"/telephony/config/locations/{loc_wid}/devices/settings", ctx), body)]
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def handle_device_settings_template_apply_device_override(
|
|
1350
|
+
data: dict, deps: dict, ctx: dict,
|
|
1351
|
+
) -> HandlerResult:
|
|
1352
|
+
"""Apply per-device settings override."""
|
|
1353
|
+
override = data.get("override") or {}
|
|
1354
|
+
device_cid = override.get("device_canonical_id")
|
|
1355
|
+
if not device_cid:
|
|
1356
|
+
return skipped(
|
|
1357
|
+
"device_settings_template:apply_device_override — "
|
|
1358
|
+
"device_canonical_id missing from override data",
|
|
1359
|
+
)
|
|
1360
|
+
device_wid = deps.get(device_cid)
|
|
1361
|
+
if not device_wid:
|
|
1362
|
+
return skipped(
|
|
1363
|
+
f"device_settings_template:apply_device_override — "
|
|
1364
|
+
f"device {device_cid!r} not resolved",
|
|
1365
|
+
)
|
|
1366
|
+
settings = override.get("settings") or {}
|
|
1367
|
+
if not settings:
|
|
1368
|
+
return []
|
|
1369
|
+
body = {"customizations": {"mpp": settings}, "customEnabled": True}
|
|
1370
|
+
return [("PUT", _url(f"/telephony/config/devices/{device_wid}/settings", ctx), body)]
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
# ---------------------------------------------------------------------------
|
|
1374
|
+
# Hoteling / Hot Desking handlers (tier 5 / tier 0)
|
|
1375
|
+
# ---------------------------------------------------------------------------
|
|
1376
|
+
|
|
1377
|
+
def handle_hoteling_guest_enable(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1378
|
+
"""Enable hoteling guest for a user with an EM device profile."""
|
|
1379
|
+
if not data.get("hoteling_guest_enabled"):
|
|
1380
|
+
return []
|
|
1381
|
+
user_cid = data.get("user_canonical_id")
|
|
1382
|
+
person_wid = deps.get(user_cid) if user_cid else None
|
|
1383
|
+
if not person_wid:
|
|
1384
|
+
return skipped(f"hoteling_guest_enable: user {user_cid!r} not resolved")
|
|
1385
|
+
return [("PUT", _url(f"/people/{person_wid}/features/hoteling", ctx), {"enabled": True})]
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def handle_hoteling_host_configure(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1389
|
+
"""Configure hoteling host on devices that were EM-enabled."""
|
|
1390
|
+
host_cids = data.get("host_device_canonical_ids") or []
|
|
1391
|
+
if not host_cids:
|
|
1392
|
+
return []
|
|
1393
|
+
user_cid = data.get("user_canonical_id")
|
|
1394
|
+
person_wid = deps.get(user_cid) if user_cid else None
|
|
1395
|
+
if not person_wid:
|
|
1396
|
+
return skipped(f"hoteling_host_configure: user {user_cid!r} not resolved")
|
|
1397
|
+
if not any(deps.get(cid) for cid in host_cids):
|
|
1398
|
+
return skipped(
|
|
1399
|
+
f"hoteling_host_configure: none of {len(host_cids)} host devices "
|
|
1400
|
+
f"resolved ({host_cids!r})",
|
|
1401
|
+
)
|
|
1402
|
+
auto_minutes = data.get("auto_logout_minutes", 0)
|
|
1403
|
+
hoteling_body: dict[str, Any] = {"enabled": True}
|
|
1404
|
+
if auto_minutes > 0:
|
|
1405
|
+
hoteling_body["limitGuestUse"] = True
|
|
1406
|
+
hoteling_body["guestHoursLimit"] = max(1, auto_minutes // 60)
|
|
1407
|
+
else:
|
|
1408
|
+
hoteling_body["limitGuestUse"] = False
|
|
1409
|
+
return [(
|
|
1410
|
+
"PUT",
|
|
1411
|
+
_url(f"/telephony/config/people/{person_wid}/devices/settings/hoteling", ctx),
|
|
1412
|
+
{"hoteling": hoteling_body},
|
|
1413
|
+
)]
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def handle_location_hotdesking_enable(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1417
|
+
"""Enable voice portal hot desk sign-in at a location."""
|
|
1418
|
+
state = data.get("pre_migration_state") or {}
|
|
1419
|
+
loc_cid = state.get("location_canonical_id")
|
|
1420
|
+
loc_wid = deps.get(loc_cid) if loc_cid else None
|
|
1421
|
+
if not loc_wid:
|
|
1422
|
+
return skipped(
|
|
1423
|
+
f"location_hotdesking_enable: location {loc_cid!r} not resolved",
|
|
1424
|
+
)
|
|
1425
|
+
return [(
|
|
1426
|
+
"PUT",
|
|
1427
|
+
_url(f"/telephony/config/locations/{loc_wid}/features/hotDesking", ctx),
|
|
1428
|
+
{"voicePortalHotDeskSignInEnabled": True},
|
|
1429
|
+
)]
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
# ---------------------------------------------------------------------------
|
|
1433
|
+
# Tier 5: Feature forwarding / holiday / night service
|
|
1434
|
+
# (from 2026-04-10-feature-forwarding-night-service.md)
|
|
1435
|
+
# ---------------------------------------------------------------------------
|
|
1436
|
+
|
|
1437
|
+
def handle_hunt_group_configure_forwarding(
|
|
1438
|
+
data: dict, deps: dict, ctx: dict,
|
|
1439
|
+
) -> HandlerResult:
|
|
1440
|
+
hg_wid = _find_webex_id(data, deps)
|
|
1441
|
+
loc_wid = _resolve_location(data, deps)
|
|
1442
|
+
if not hg_wid:
|
|
1443
|
+
return skipped(
|
|
1444
|
+
f"hunt group {data.get('name')!r} not created — "
|
|
1445
|
+
"cannot configure forwarding"
|
|
1446
|
+
)
|
|
1447
|
+
if not loc_wid:
|
|
1448
|
+
return skipped(
|
|
1449
|
+
f"hunt group {data.get('name')!r}: location not resolved — "
|
|
1450
|
+
"cannot configure forwarding"
|
|
1451
|
+
)
|
|
1452
|
+
cf: dict[str, Any] = {}
|
|
1453
|
+
if data.get("forward_always_enabled") and data.get("forward_always_destination"):
|
|
1454
|
+
cf["always"] = {
|
|
1455
|
+
"enabled": True,
|
|
1456
|
+
"destination": data["forward_always_destination"],
|
|
1457
|
+
"ringReminderEnabled": False,
|
|
1458
|
+
"destinationVoicemailEnabled": False,
|
|
1459
|
+
}
|
|
1460
|
+
if data.get("forward_no_answer_enabled") and data.get("forward_no_answer_destination"):
|
|
1461
|
+
cf["selective"] = {
|
|
1462
|
+
"enabled": True,
|
|
1463
|
+
"destination": data["forward_no_answer_destination"],
|
|
1464
|
+
"ringReminderEnabled": False,
|
|
1465
|
+
"destinationVoicemailEnabled": False,
|
|
1466
|
+
}
|
|
1467
|
+
if data.get("forward_busy_enabled") and data.get("forward_busy_destination") and "always" not in cf:
|
|
1468
|
+
# Map CUCM forward-on-busy onto Webex always-forward when always is unused
|
|
1469
|
+
cf["always"] = {
|
|
1470
|
+
"enabled": True,
|
|
1471
|
+
"destination": data["forward_busy_destination"],
|
|
1472
|
+
"ringReminderEnabled": False,
|
|
1473
|
+
"destinationVoicemailEnabled": False,
|
|
1474
|
+
}
|
|
1475
|
+
if not cf:
|
|
1476
|
+
return []
|
|
1477
|
+
body = {"callForwarding": cf}
|
|
1478
|
+
url = _url(
|
|
1479
|
+
f"/telephony/config/locations/{loc_wid}/huntGroups/{hg_wid}/callForwarding",
|
|
1480
|
+
ctx,
|
|
1481
|
+
)
|
|
1482
|
+
return [("PUT", url, body)]
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def handle_call_queue_configure_forwarding(
|
|
1486
|
+
data: dict, deps: dict, ctx: dict,
|
|
1487
|
+
) -> HandlerResult:
|
|
1488
|
+
cq_wid = _find_webex_id(data, deps)
|
|
1489
|
+
loc_wid = _resolve_location(data, deps)
|
|
1490
|
+
if not cq_wid:
|
|
1491
|
+
return skipped(
|
|
1492
|
+
f"call queue {data.get('name')!r} not created — "
|
|
1493
|
+
"cannot configure forwarding"
|
|
1494
|
+
)
|
|
1495
|
+
if not loc_wid:
|
|
1496
|
+
return skipped(
|
|
1497
|
+
f"call queue {data.get('name')!r}: location not resolved — "
|
|
1498
|
+
"cannot configure forwarding"
|
|
1499
|
+
)
|
|
1500
|
+
cf: dict[str, Any] = {}
|
|
1501
|
+
always_dest = (
|
|
1502
|
+
data.get("forward_always_destination")
|
|
1503
|
+
or data.get("queue_full_destination")
|
|
1504
|
+
)
|
|
1505
|
+
if always_dest:
|
|
1506
|
+
cf["always"] = {
|
|
1507
|
+
"enabled": True,
|
|
1508
|
+
"destination": always_dest,
|
|
1509
|
+
"ringReminderEnabled": False,
|
|
1510
|
+
"destinationVoicemailEnabled": False,
|
|
1511
|
+
}
|
|
1512
|
+
if not cf:
|
|
1513
|
+
return []
|
|
1514
|
+
body = {"callForwarding": cf}
|
|
1515
|
+
url = _url(
|
|
1516
|
+
f"/telephony/config/locations/{loc_wid}/queues/{cq_wid}/callForwarding",
|
|
1517
|
+
ctx,
|
|
1518
|
+
)
|
|
1519
|
+
return [("PUT", url, body)]
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def handle_call_queue_configure_holiday_service(
|
|
1523
|
+
data: dict, deps: dict, ctx: dict,
|
|
1524
|
+
) -> HandlerResult:
|
|
1525
|
+
cq_wid = _find_webex_id(data, deps)
|
|
1526
|
+
loc_wid = _resolve_location(data, deps)
|
|
1527
|
+
if not cq_wid:
|
|
1528
|
+
return skipped(
|
|
1529
|
+
f"call queue {data.get('name')!r} not created — "
|
|
1530
|
+
"cannot configure holiday service"
|
|
1531
|
+
)
|
|
1532
|
+
if not loc_wid:
|
|
1533
|
+
return skipped(
|
|
1534
|
+
f"call queue {data.get('name')!r}: location not resolved — "
|
|
1535
|
+
"cannot configure holiday service"
|
|
1536
|
+
)
|
|
1537
|
+
if not data.get("holiday_service_enabled"):
|
|
1538
|
+
return []
|
|
1539
|
+
body: dict[str, Any] = {
|
|
1540
|
+
"holidayServiceEnabled": True,
|
|
1541
|
+
"action": data.get("holiday_action", "BUSY"),
|
|
1542
|
+
"holidayScheduleLevel": data.get("holiday_schedule_level", "LOCATION"),
|
|
1543
|
+
"holidayScheduleName": data.get("holiday_schedule_name"),
|
|
1544
|
+
"playAnnouncementBeforeEnabled": True,
|
|
1545
|
+
"audioMessageSelection": "DEFAULT",
|
|
1546
|
+
}
|
|
1547
|
+
if data.get("holiday_action") == "TRANSFER" and data.get("holiday_transfer_number"):
|
|
1548
|
+
body["transferPhoneNumber"] = data["holiday_transfer_number"]
|
|
1549
|
+
url = _url(
|
|
1550
|
+
f"/telephony/config/locations/{loc_wid}/queues/{cq_wid}/holidayService",
|
|
1551
|
+
ctx,
|
|
1552
|
+
)
|
|
1553
|
+
return [("PUT", url, body)]
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def handle_call_queue_configure_night_service(
|
|
1557
|
+
data: dict, deps: dict, ctx: dict,
|
|
1558
|
+
) -> HandlerResult:
|
|
1559
|
+
cq_wid = _find_webex_id(data, deps)
|
|
1560
|
+
loc_wid = _resolve_location(data, deps)
|
|
1561
|
+
if not cq_wid:
|
|
1562
|
+
return skipped(
|
|
1563
|
+
f"call queue {data.get('name')!r} not created — "
|
|
1564
|
+
"cannot configure night service"
|
|
1565
|
+
)
|
|
1566
|
+
if not loc_wid:
|
|
1567
|
+
return skipped(
|
|
1568
|
+
f"call queue {data.get('name')!r}: location not resolved — "
|
|
1569
|
+
"cannot configure night service"
|
|
1570
|
+
)
|
|
1571
|
+
if not data.get("night_service_enabled"):
|
|
1572
|
+
return []
|
|
1573
|
+
body: dict[str, Any] = {
|
|
1574
|
+
"nightServiceEnabled": True,
|
|
1575
|
+
"action": data.get("night_action", "TRANSFER"),
|
|
1576
|
+
"businessHoursLevel": data.get("night_business_hours_level", "LOCATION"),
|
|
1577
|
+
"businessHoursName": data.get("night_business_hours_name"),
|
|
1578
|
+
"playAnnouncementBeforeEnabled": True,
|
|
1579
|
+
"announcementMode": "NORMAL",
|
|
1580
|
+
"audioMessageSelection": "DEFAULT",
|
|
1581
|
+
"forceNightServiceEnabled": False,
|
|
1582
|
+
"manualAudioMessageSelection": "DEFAULT",
|
|
1583
|
+
}
|
|
1584
|
+
if data.get("night_transfer_number"):
|
|
1585
|
+
body["transferPhoneNumber"] = data["night_transfer_number"]
|
|
1586
|
+
url = _url(
|
|
1587
|
+
f"/telephony/config/locations/{loc_wid}/queues/{cq_wid}/nightService",
|
|
1588
|
+
ctx,
|
|
1589
|
+
)
|
|
1590
|
+
return [("PUT", url, body)]
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def handle_call_queue_configure_stranded_calls(
|
|
1594
|
+
data: dict, deps: dict, ctx: dict,
|
|
1595
|
+
) -> HandlerResult:
|
|
1596
|
+
cq_wid = _find_webex_id(data, deps)
|
|
1597
|
+
loc_wid = _resolve_location(data, deps)
|
|
1598
|
+
if not cq_wid:
|
|
1599
|
+
return skipped(
|
|
1600
|
+
f"call queue {data.get('name')!r} not created — "
|
|
1601
|
+
"cannot configure stranded calls"
|
|
1602
|
+
)
|
|
1603
|
+
if not loc_wid:
|
|
1604
|
+
return skipped(
|
|
1605
|
+
f"call queue {data.get('name')!r}: location not resolved — "
|
|
1606
|
+
"cannot configure stranded calls"
|
|
1607
|
+
)
|
|
1608
|
+
if not data.get("no_agent_destination"):
|
|
1609
|
+
return []
|
|
1610
|
+
body: dict[str, Any] = {
|
|
1611
|
+
"action": "TRANSFER",
|
|
1612
|
+
"transferPhoneNumber": data["no_agent_destination"],
|
|
1613
|
+
}
|
|
1614
|
+
url = _url(
|
|
1615
|
+
f"/telephony/config/locations/{loc_wid}/queues/{cq_wid}/strandedCalls",
|
|
1616
|
+
ctx,
|
|
1617
|
+
)
|
|
1618
|
+
return [("PUT", url, body)]
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
def handle_auto_attendant_configure_forwarding(
|
|
1622
|
+
data: dict, deps: dict, ctx: dict,
|
|
1623
|
+
) -> HandlerResult:
|
|
1624
|
+
aa_wid = _find_webex_id(data, deps)
|
|
1625
|
+
loc_wid = _resolve_location(data, deps) or _resolve_location_from_deps(deps)
|
|
1626
|
+
if not aa_wid:
|
|
1627
|
+
return skipped(
|
|
1628
|
+
f"auto attendant {data.get('name')!r} not created — "
|
|
1629
|
+
"cannot configure forwarding"
|
|
1630
|
+
)
|
|
1631
|
+
if not loc_wid:
|
|
1632
|
+
return skipped(
|
|
1633
|
+
f"auto attendant {data.get('name')!r}: location not resolved — "
|
|
1634
|
+
"cannot configure forwarding"
|
|
1635
|
+
)
|
|
1636
|
+
if not (data.get("forward_always_enabled") and data.get("forward_always_destination")):
|
|
1637
|
+
return []
|
|
1638
|
+
body = {
|
|
1639
|
+
"callForwarding": {
|
|
1640
|
+
"always": {
|
|
1641
|
+
"enabled": True,
|
|
1642
|
+
"destination": data["forward_always_destination"],
|
|
1643
|
+
"ringReminderEnabled": False,
|
|
1644
|
+
"destinationVoicemailEnabled": False,
|
|
1645
|
+
},
|
|
1646
|
+
},
|
|
1647
|
+
}
|
|
1648
|
+
url = _url(
|
|
1649
|
+
f"/telephony/config/locations/{loc_wid}/autoAttendants/{aa_wid}/callForwarding",
|
|
1650
|
+
ctx,
|
|
1651
|
+
)
|
|
1652
|
+
return [("PUT", url, body)]
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
# ---------------------------------------------------------------------------
|
|
1656
|
+
# Bulk job handlers (Phase: bulk-operations)
|
|
1657
|
+
# Submit jobs that replace per-device operations when device count exceeds
|
|
1658
|
+
# the configured threshold. Polling is handled by the engine, not the handler.
|
|
1659
|
+
# (from docs/superpowers/specs/2026-04-10-bulk-operations.md §2, §3d)
|
|
1660
|
+
# ---------------------------------------------------------------------------
|
|
1661
|
+
|
|
1662
|
+
def handle_bulk_device_settings_submit(
|
|
1663
|
+
data: dict, deps: dict, ctx: dict,
|
|
1664
|
+
) -> HandlerResult:
|
|
1665
|
+
"""Submit a bulk Change Device Settings job for a location.
|
|
1666
|
+
|
|
1667
|
+
POST /v1/telephony/config/jobs/devices/callDeviceSettings
|
|
1668
|
+
(spec §2b)
|
|
1669
|
+
"""
|
|
1670
|
+
loc_cid = data.get("location_canonical_id", "")
|
|
1671
|
+
loc_wid = deps.get(loc_cid) or _resolve_location_from_deps(deps)
|
|
1672
|
+
if not loc_wid:
|
|
1673
|
+
return skipped(
|
|
1674
|
+
f"bulk_device_settings:submit — location {loc_cid!r} not resolved"
|
|
1675
|
+
)
|
|
1676
|
+
body: dict[str, Any] = {
|
|
1677
|
+
"locationId": loc_wid,
|
|
1678
|
+
"locationCustomizationsEnabled": True,
|
|
1679
|
+
"customizations": data.get("customizations", {}),
|
|
1680
|
+
}
|
|
1681
|
+
return [("POST", _url("/telephony/config/jobs/devices/callDeviceSettings", ctx), body)]
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def handle_bulk_line_key_template_submit(
|
|
1685
|
+
data: dict, deps: dict, ctx: dict,
|
|
1686
|
+
) -> HandlerResult:
|
|
1687
|
+
"""Submit a bulk Apply Line Key Template job.
|
|
1688
|
+
|
|
1689
|
+
POST /v1/telephony/config/jobs/devices/applyLineKeyTemplate
|
|
1690
|
+
(spec §2a)
|
|
1691
|
+
"""
|
|
1692
|
+
template_cid = data.get("template_canonical_id", "")
|
|
1693
|
+
template_wid = deps.get(template_cid)
|
|
1694
|
+
if not template_wid:
|
|
1695
|
+
return skipped(
|
|
1696
|
+
f"bulk_line_key_template:submit — line key template "
|
|
1697
|
+
f"{template_cid!r} not created"
|
|
1698
|
+
)
|
|
1699
|
+
loc_wids = [
|
|
1700
|
+
deps[cid]
|
|
1701
|
+
for cid in data.get("location_canonical_ids", [])
|
|
1702
|
+
if cid in deps and deps[cid]
|
|
1703
|
+
]
|
|
1704
|
+
body: dict[str, Any] = {
|
|
1705
|
+
"action": "APPLY_TEMPLATE",
|
|
1706
|
+
"templateId": template_wid,
|
|
1707
|
+
}
|
|
1708
|
+
if loc_wids:
|
|
1709
|
+
body["locationIds"] = loc_wids
|
|
1710
|
+
return [("POST", _url("/telephony/config/jobs/devices/applyLineKeyTemplate", ctx), body)]
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
def handle_bulk_dynamic_settings_submit(
|
|
1714
|
+
data: dict, deps: dict, ctx: dict,
|
|
1715
|
+
) -> HandlerResult:
|
|
1716
|
+
"""Submit a bulk Update Dynamic Device Settings job.
|
|
1717
|
+
|
|
1718
|
+
POST /v1/telephony/config/jobs/devices/dynamicDeviceSettings
|
|
1719
|
+
(spec §2d)
|
|
1720
|
+
"""
|
|
1721
|
+
loc_cid = data.get("location_canonical_id", "")
|
|
1722
|
+
loc_wid = deps.get(loc_cid, "") if loc_cid else ""
|
|
1723
|
+
if not loc_wid:
|
|
1724
|
+
return skipped(
|
|
1725
|
+
f"bulk_dynamic_settings:submit — location {loc_cid!r} not resolved"
|
|
1726
|
+
)
|
|
1727
|
+
body: dict[str, Any] = {
|
|
1728
|
+
"locationId": loc_wid,
|
|
1729
|
+
"tags": data.get("tags", []),
|
|
1730
|
+
}
|
|
1731
|
+
return [("POST", _url("/telephony/config/jobs/devices/dynamicDeviceSettings", ctx), body)]
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
def handle_bulk_rebuild_phones_submit(
|
|
1735
|
+
data: dict, deps: dict, ctx: dict,
|
|
1736
|
+
) -> HandlerResult:
|
|
1737
|
+
"""Submit a bulk Rebuild Phones job for a location.
|
|
1738
|
+
|
|
1739
|
+
POST /v1/telephony/config/jobs/devices/rebuildPhones
|
|
1740
|
+
(spec §2c). Not supported for Webex for Government.
|
|
1741
|
+
"""
|
|
1742
|
+
loc_cid = data.get("location_canonical_id", "")
|
|
1743
|
+
loc_wid = deps.get(loc_cid) or _resolve_location_from_deps(deps)
|
|
1744
|
+
if not loc_wid:
|
|
1745
|
+
return skipped(
|
|
1746
|
+
f"bulk_rebuild_phones:submit — location {loc_cid!r} not resolved"
|
|
1747
|
+
)
|
|
1748
|
+
body: dict[str, Any] = {"locationId": loc_wid}
|
|
1749
|
+
return [("POST", _url("/telephony/config/jobs/devices/rebuildPhones", ctx), body)]
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def handle_music_on_hold_configure(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1753
|
+
"""Phase A no-op. The op appears in the deployment plan so operators
|
|
1754
|
+
see music_on_hold tracked, but no API call is made. Real per-location
|
|
1755
|
+
MOH configuration + custom audio upload is deferred to Phase B.
|
|
1756
|
+
|
|
1757
|
+
Phase B prerequisites:
|
|
1758
|
+
1. MOHMapper sets location_canonical_id (one object per location)
|
|
1759
|
+
2. MOHMapper writes moh_in_location cross-ref → dependency rule activates
|
|
1760
|
+
3. API_CALL_ESTIMATES["music_on_hold:configure"] updated from 0 to 1
|
|
1761
|
+
4. This handler upgraded to: PUT /telephony/config/locations/{locId}/musicOnHold
|
|
1762
|
+
with greeting=SYSTEM (default) or greeting=CUSTOM + audioFile.id (custom)
|
|
1763
|
+
5. engine.py multipart support for custom audio upload (aiohttp.FormData)
|
|
1764
|
+
"""
|
|
1765
|
+
return []
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def handle_announcement_upload(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1769
|
+
"""Phase A no-op. The AnnouncementMapper creates AUDIO_ASSET_MANUAL
|
|
1770
|
+
decisions for every announcement, so operators already know to manually
|
|
1771
|
+
download/upload. The op exists in the plan for visibility. Real multipart
|
|
1772
|
+
upload is deferred to Phase B alongside engine multipart support.
|
|
1773
|
+
|
|
1774
|
+
Phase B prerequisites:
|
|
1775
|
+
1. AnnouncementMapper sets location_canonical_id (from associated feature's location)
|
|
1776
|
+
2. AnnouncementMapper writes announcement_in_location cross-ref → dependency rule activates
|
|
1777
|
+
3. API_CALL_ESTIMATES["announcement:upload"] updated from 0 to 1
|
|
1778
|
+
4. engine.py multipart support (aiohttp.FormData) for binary WAV upload
|
|
1779
|
+
5. This handler upgraded to: POST /telephony/config/locations/{locId}/announcements
|
|
1780
|
+
with multipart/form-data (name field + binary file field)
|
|
1781
|
+
6. cucm-collect script integration to pre-download audio files from CUCM
|
|
1782
|
+
"""
|
|
1783
|
+
return []
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
# ---------------------------------------------------------------------------
|
|
1787
|
+
# Tier 5: Executive/assistant pairing
|
|
1788
|
+
# (from 2026-04-10-executive-assistant-migration.md §4e)
|
|
1789
|
+
# ---------------------------------------------------------------------------
|
|
1790
|
+
|
|
1791
|
+
def handle_executive_type_assign(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1792
|
+
"""Set EXECUTIVE type on the executive person.
|
|
1793
|
+
|
|
1794
|
+
PUT /people/{personId}/features/executiveAssistant
|
|
1795
|
+
Scope: spark-admin:people_write
|
|
1796
|
+
(spec §3a)
|
|
1797
|
+
"""
|
|
1798
|
+
exec_cid = data.get("executive_canonical_id")
|
|
1799
|
+
person_wid = deps.get(exec_cid) if exec_cid else None
|
|
1800
|
+
if not person_wid:
|
|
1801
|
+
return skipped(
|
|
1802
|
+
f"executive user {exec_cid!r} not resolved — "
|
|
1803
|
+
"cannot set EXECUTIVE type"
|
|
1804
|
+
)
|
|
1805
|
+
return [(
|
|
1806
|
+
"PUT",
|
|
1807
|
+
_url(f"/people/{person_wid}/features/executiveAssistant", ctx),
|
|
1808
|
+
{"type": "EXECUTIVE"},
|
|
1809
|
+
)]
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
def handle_assistant_type_assign(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1813
|
+
"""Set EXECUTIVE_ASSISTANT type on each assistant person.
|
|
1814
|
+
|
|
1815
|
+
PUT /people/{personId}/features/executiveAssistant (once per assistant)
|
|
1816
|
+
Scope: spark-admin:people_write
|
|
1817
|
+
(spec §3a)
|
|
1818
|
+
"""
|
|
1819
|
+
asst_cids = list(data.get("assistant_canonical_ids", []))
|
|
1820
|
+
calls: list[tuple[str, str, dict | None]] = []
|
|
1821
|
+
unresolved: list[str] = []
|
|
1822
|
+
for asst_cid in asst_cids:
|
|
1823
|
+
person_wid = deps.get(asst_cid)
|
|
1824
|
+
if person_wid:
|
|
1825
|
+
calls.append((
|
|
1826
|
+
"PUT",
|
|
1827
|
+
_url(f"/people/{person_wid}/features/executiveAssistant", ctx),
|
|
1828
|
+
{"type": "EXECUTIVE_ASSISTANT"},
|
|
1829
|
+
))
|
|
1830
|
+
else:
|
|
1831
|
+
unresolved.append(asst_cid)
|
|
1832
|
+
# If assistant_cids were specified but NONE resolved, this is a missing-dep
|
|
1833
|
+
# scenario (all assistant user-creates failed/skipped). Surface it as SKIPPED
|
|
1834
|
+
# so the op doesn't silently look completed. An empty assistant_cids list is
|
|
1835
|
+
# a true no-op (nothing to configure) — keep returning [].
|
|
1836
|
+
if asst_cids and not calls:
|
|
1837
|
+
return skipped(
|
|
1838
|
+
f"assistant_type_assign: none of the {len(unresolved)} assistant(s) "
|
|
1839
|
+
f"resolved ({unresolved!r})"
|
|
1840
|
+
)
|
|
1841
|
+
return calls
|
|
1842
|
+
|
|
1843
|
+
|
|
1844
|
+
def handle_executive_assign_assistants(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1845
|
+
"""Pair assistants to the executive.
|
|
1846
|
+
|
|
1847
|
+
PUT /telephony/config/people/{personId}/executive/assignedAssistants
|
|
1848
|
+
Scope: spark-admin:telephony_config_write
|
|
1849
|
+
(spec §3b)
|
|
1850
|
+
"""
|
|
1851
|
+
exec_cid = data.get("executive_canonical_id")
|
|
1852
|
+
person_wid = deps.get(exec_cid) if exec_cid else None
|
|
1853
|
+
if not person_wid:
|
|
1854
|
+
return skipped(
|
|
1855
|
+
f"executive user {exec_cid!r} not resolved — "
|
|
1856
|
+
"cannot assign assistants"
|
|
1857
|
+
)
|
|
1858
|
+
asst_cids = list(data.get("assistant_canonical_ids", []))
|
|
1859
|
+
assistants = []
|
|
1860
|
+
unresolved: list[str] = []
|
|
1861
|
+
for asst_cid in asst_cids:
|
|
1862
|
+
asst_wid = deps.get(asst_cid)
|
|
1863
|
+
if asst_wid:
|
|
1864
|
+
assistants.append({"id": asst_wid, "optInEnabled": True})
|
|
1865
|
+
else:
|
|
1866
|
+
unresolved.append(asst_cid)
|
|
1867
|
+
if not assistants:
|
|
1868
|
+
# assistant_cids were specified (or empty). Either way, zero resolved
|
|
1869
|
+
# assistants means we have nothing to pair. If the plan expected at
|
|
1870
|
+
# least one, surface SKIPPED with the unresolved list so this doesn't
|
|
1871
|
+
# look silently completed.
|
|
1872
|
+
if asst_cids:
|
|
1873
|
+
return skipped(
|
|
1874
|
+
f"executive_assign_assistants: none of the {len(unresolved)} "
|
|
1875
|
+
f"assistant(s) resolved ({unresolved!r})"
|
|
1876
|
+
)
|
|
1877
|
+
return []
|
|
1878
|
+
body: dict[str, Any] = {
|
|
1879
|
+
"allowOptInEnabled": True,
|
|
1880
|
+
"assistants": assistants,
|
|
1881
|
+
}
|
|
1882
|
+
return [(
|
|
1883
|
+
"PUT",
|
|
1884
|
+
_url(f"/telephony/config/people/{person_wid}/executive/assignedAssistants", ctx),
|
|
1885
|
+
body,
|
|
1886
|
+
)]
|
|
1887
|
+
|
|
1888
|
+
|
|
1889
|
+
def handle_executive_configure_alert(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1890
|
+
"""Configure executive alert settings (alerting mode, rollover).
|
|
1891
|
+
|
|
1892
|
+
PUT /telephony/config/people/{personId}/executive/alert
|
|
1893
|
+
Scope: spark-admin:telephony_config_write
|
|
1894
|
+
(spec §3c)
|
|
1895
|
+
"""
|
|
1896
|
+
exec_cid = data.get("executive_canonical_id")
|
|
1897
|
+
person_wid = deps.get(exec_cid) if exec_cid else None
|
|
1898
|
+
if not person_wid:
|
|
1899
|
+
return skipped(
|
|
1900
|
+
f"executive user {exec_cid!r} not resolved — "
|
|
1901
|
+
"cannot configure alert settings"
|
|
1902
|
+
)
|
|
1903
|
+
alerting_mode = data.get("alerting_mode", "SIMULTANEOUS")
|
|
1904
|
+
body: dict[str, Any] = {
|
|
1905
|
+
"alertingMode": alerting_mode,
|
|
1906
|
+
"nextAssistantNumberOfRings": 3,
|
|
1907
|
+
"rolloverEnabled": True,
|
|
1908
|
+
"rolloverAction": "VOICE_MESSAGING",
|
|
1909
|
+
"rolloverWaitTimeInSecs": 20,
|
|
1910
|
+
"clidNameMode": "EXECUTIVE_ORIGINATOR",
|
|
1911
|
+
"clidPhoneNumberMode": "EXECUTIVE",
|
|
1912
|
+
}
|
|
1913
|
+
return [(
|
|
1914
|
+
"PUT",
|
|
1915
|
+
_url(f"/telephony/config/people/{person_wid}/executive/alert", ctx),
|
|
1916
|
+
body,
|
|
1917
|
+
)]
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
def handle_executive_configure_filtering(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1921
|
+
"""Configure executive call filtering.
|
|
1922
|
+
|
|
1923
|
+
PUT /telephony/config/people/{personId}/executive/callFiltering
|
|
1924
|
+
Scope: spark-admin:telephony_config_write
|
|
1925
|
+
(spec §3d)
|
|
1926
|
+
"""
|
|
1927
|
+
exec_cid = data.get("executive_canonical_id")
|
|
1928
|
+
person_wid = deps.get(exec_cid) if exec_cid else None
|
|
1929
|
+
if not person_wid:
|
|
1930
|
+
return skipped(
|
|
1931
|
+
f"executive user {exec_cid!r} not resolved — "
|
|
1932
|
+
"cannot configure call filtering"
|
|
1933
|
+
)
|
|
1934
|
+
if not data.get("filter_enabled"):
|
|
1935
|
+
return []
|
|
1936
|
+
# Map CUCM filter types to Webex filter types
|
|
1937
|
+
_filter_type_map: dict[str, str] = {
|
|
1938
|
+
"ALL_CALLS": "ALL_CALLS",
|
|
1939
|
+
"INTERNAL_ONLY": "ALL_INTERNAL_CALLS",
|
|
1940
|
+
"EXTERNAL_ONLY": "ALL_EXTERNAL_CALLS",
|
|
1941
|
+
}
|
|
1942
|
+
raw_filter_type = data.get("filter_type", "ALL_CALLS")
|
|
1943
|
+
filter_type = _filter_type_map.get(raw_filter_type, "ALL_CALLS")
|
|
1944
|
+
body: dict[str, Any] = {
|
|
1945
|
+
"enabled": True,
|
|
1946
|
+
"filterType": filter_type,
|
|
1947
|
+
}
|
|
1948
|
+
return [(
|
|
1949
|
+
"PUT",
|
|
1950
|
+
_url(f"/telephony/config/people/{person_wid}/executive/callFiltering", ctx),
|
|
1951
|
+
body,
|
|
1952
|
+
)]
|
|
1953
|
+
|
|
1954
|
+
|
|
1955
|
+
def handle_executive_configure_screening(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1956
|
+
"""Configure executive call screening.
|
|
1957
|
+
|
|
1958
|
+
PUT /telephony/config/people/{personId}/executive/screening
|
|
1959
|
+
Scope: spark-admin:telephony_config_write
|
|
1960
|
+
(spec §3e)
|
|
1961
|
+
"""
|
|
1962
|
+
exec_cid = data.get("executive_canonical_id")
|
|
1963
|
+
person_wid = deps.get(exec_cid) if exec_cid else None
|
|
1964
|
+
if not person_wid:
|
|
1965
|
+
return skipped(
|
|
1966
|
+
f"executive user {exec_cid!r} not resolved — "
|
|
1967
|
+
"cannot configure call screening"
|
|
1968
|
+
)
|
|
1969
|
+
if not data.get("screening_enabled"):
|
|
1970
|
+
return []
|
|
1971
|
+
body: dict[str, Any] = {
|
|
1972
|
+
"enabled": True,
|
|
1973
|
+
"alertType": "RING_SPLASH",
|
|
1974
|
+
}
|
|
1975
|
+
return [(
|
|
1976
|
+
"PUT",
|
|
1977
|
+
_url(f"/telephony/config/people/{person_wid}/executive/screening", ctx),
|
|
1978
|
+
body,
|
|
1979
|
+
)]
|
|
1980
|
+
|
|
1981
|
+
|
|
1982
|
+
def handle_assistant_configure_settings(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
1983
|
+
"""Configure assistant forward/opt-in settings for each assistant.
|
|
1984
|
+
|
|
1985
|
+
PUT /telephony/config/people/{personId}/executive/assistant (once per assistant)
|
|
1986
|
+
Scope: spark-admin:telephony_config_write
|
|
1987
|
+
(spec §3f)
|
|
1988
|
+
"""
|
|
1989
|
+
exec_cid = data.get("executive_canonical_id")
|
|
1990
|
+
exec_wid = deps.get(exec_cid) if exec_cid else None
|
|
1991
|
+
|
|
1992
|
+
asst_cids = list(data.get("assistant_canonical_ids", []))
|
|
1993
|
+
calls: list[tuple[str, str, dict | None]] = []
|
|
1994
|
+
unresolved: list[str] = []
|
|
1995
|
+
for asst_cid in asst_cids:
|
|
1996
|
+
asst_wid = deps.get(asst_cid)
|
|
1997
|
+
if not asst_wid:
|
|
1998
|
+
unresolved.append(asst_cid)
|
|
1999
|
+
continue
|
|
2000
|
+
body: dict[str, Any] = {
|
|
2001
|
+
"forwardFilteredCallsEnabled": False,
|
|
2002
|
+
}
|
|
2003
|
+
if exec_wid:
|
|
2004
|
+
body["executives"] = [{"personId": exec_wid, "optInEnabled": True}]
|
|
2005
|
+
calls.append((
|
|
2006
|
+
"PUT",
|
|
2007
|
+
_url(f"/telephony/config/people/{asst_wid}/executive/assistant", ctx),
|
|
2008
|
+
body,
|
|
2009
|
+
))
|
|
2010
|
+
# If assistant_cids were specified but NONE resolved, surface SKIPPED so
|
|
2011
|
+
# the op doesn't silently look completed. An empty assistant_cids list is
|
|
2012
|
+
# a true no-op (nothing to configure).
|
|
2013
|
+
if asst_cids and not calls:
|
|
2014
|
+
return skipped(
|
|
2015
|
+
f"assistant_configure_settings: none of the {len(unresolved)} "
|
|
2016
|
+
f"assistant(s) resolved ({unresolved!r})"
|
|
2017
|
+
)
|
|
2018
|
+
return calls
|
|
2019
|
+
|
|
2020
|
+
|
|
2021
|
+
# ---------------------------------------------------------------------------
|
|
2022
|
+
# DECT Network provisioning (Tier 2/3)
|
|
2023
|
+
# ---------------------------------------------------------------------------
|
|
2024
|
+
|
|
2025
|
+
def handle_dect_network_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
2026
|
+
"""Create a DECT network at a location.
|
|
2027
|
+
|
|
2028
|
+
POST /telephony/config/locations/{locationId}/dectNetworks
|
|
2029
|
+
Body: { name, displayName, model, defaultAccessCodeEnabled, defaultAccessCode }
|
|
2030
|
+
Returns dectNetworkId in response.
|
|
2031
|
+
(from spec §5e — handle_dect_network_create)
|
|
2032
|
+
"""
|
|
2033
|
+
loc_wid = _resolve_location(data, deps) or _resolve_location_from_deps(deps)
|
|
2034
|
+
if not loc_wid:
|
|
2035
|
+
return skipped(
|
|
2036
|
+
f"DECT network {data.get('network_name') or data.get('display_name')!r}: "
|
|
2037
|
+
"location not resolved — cannot create"
|
|
2038
|
+
)
|
|
2039
|
+
|
|
2040
|
+
name = data.get("network_name") or data.get("display_name")
|
|
2041
|
+
if not name:
|
|
2042
|
+
return []
|
|
2043
|
+
|
|
2044
|
+
access_code = data.get("access_code")
|
|
2045
|
+
body: dict[str, Any] = {
|
|
2046
|
+
"name": name,
|
|
2047
|
+
"displayName": data.get("display_name") or name,
|
|
2048
|
+
"model": data.get("model"),
|
|
2049
|
+
"defaultAccessCodeEnabled": access_code is not None,
|
|
2050
|
+
"defaultAccessCode": access_code,
|
|
2051
|
+
}
|
|
2052
|
+
return [("POST", _url(f"/telephony/config/locations/{loc_wid}/dectNetworks", ctx), body)]
|
|
2053
|
+
|
|
2054
|
+
|
|
2055
|
+
def handle_dect_base_station_create(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
2056
|
+
"""Register base stations with a DECT network by MAC address.
|
|
2057
|
+
|
|
2058
|
+
POST /telephony/config/locations/{locationId}/dectNetworks/{dectNetworkId}/baseStations
|
|
2059
|
+
Body: { baseStationMacs: [...] }
|
|
2060
|
+
(from spec §5e — handle_dect_base_station_create)
|
|
2061
|
+
"""
|
|
2062
|
+
loc_wid = _resolve_location(data, deps) or _resolve_location_from_deps(deps)
|
|
2063
|
+
if not loc_wid:
|
|
2064
|
+
return skipped(
|
|
2065
|
+
f"DECT base stations for {data.get('canonical_id')!r}: "
|
|
2066
|
+
"location not resolved"
|
|
2067
|
+
)
|
|
2068
|
+
|
|
2069
|
+
cid = data.get("canonical_id", "")
|
|
2070
|
+
dect_network_wid = deps.get(cid)
|
|
2071
|
+
if not dect_network_wid:
|
|
2072
|
+
return skipped(
|
|
2073
|
+
f"DECT network {cid!r} not created — "
|
|
2074
|
+
"cannot register base stations"
|
|
2075
|
+
)
|
|
2076
|
+
|
|
2077
|
+
# MACs come from base_stations list — each entry has a 'mac' field
|
|
2078
|
+
base_stations = data.get("base_stations") or []
|
|
2079
|
+
macs = [bs["mac"] for bs in base_stations if bs.get("mac")]
|
|
2080
|
+
if not macs:
|
|
2081
|
+
return []
|
|
2082
|
+
|
|
2083
|
+
body: dict[str, Any] = {"baseStationMacs": macs}
|
|
2084
|
+
return [(
|
|
2085
|
+
"POST",
|
|
2086
|
+
_url(
|
|
2087
|
+
f"/telephony/config/locations/{loc_wid}/dectNetworks/{dect_network_wid}/baseStations",
|
|
2088
|
+
ctx,
|
|
2089
|
+
),
|
|
2090
|
+
body,
|
|
2091
|
+
)]
|
|
2092
|
+
|
|
2093
|
+
|
|
2094
|
+
def handle_dect_handset_assign(data: dict, deps: dict, ctx: dict) -> HandlerResult:
|
|
2095
|
+
"""Assign handsets to users/workspaces in a DECT network (bulk, batches of 50).
|
|
2096
|
+
|
|
2097
|
+
POST /telephony/config/locations/{locationId}/dectNetworks/{dectNetworkId}/handsets/bulk
|
|
2098
|
+
Body: { items: [ { line1MemberId, customDisplayName, line2MemberId? } ] }
|
|
2099
|
+
(from spec §5e — handle_dect_handset_assign)
|
|
2100
|
+
"""
|
|
2101
|
+
loc_wid = _resolve_location(data, deps) or _resolve_location_from_deps(deps)
|
|
2102
|
+
if not loc_wid:
|
|
2103
|
+
return skipped(
|
|
2104
|
+
f"DECT handsets for {data.get('canonical_id')!r}: "
|
|
2105
|
+
"location not resolved"
|
|
2106
|
+
)
|
|
2107
|
+
|
|
2108
|
+
cid = data.get("canonical_id", "")
|
|
2109
|
+
dect_network_wid = deps.get(cid)
|
|
2110
|
+
if not dect_network_wid:
|
|
2111
|
+
return skipped(
|
|
2112
|
+
f"DECT network {cid!r} not created — "
|
|
2113
|
+
"cannot assign handsets"
|
|
2114
|
+
)
|
|
2115
|
+
|
|
2116
|
+
handset_assignments = data.get("handset_assignments") or []
|
|
2117
|
+
items: list[dict[str, Any]] = []
|
|
2118
|
+
for assignment in handset_assignments:
|
|
2119
|
+
owner_cid = assignment.get("user_canonical_id")
|
|
2120
|
+
line1_wid = deps.get(owner_cid) if owner_cid else None
|
|
2121
|
+
if not line1_wid:
|
|
2122
|
+
# Try resolving from line1_canonical_id directly
|
|
2123
|
+
line1_cid = assignment.get("line1_canonical_id")
|
|
2124
|
+
line1_wid = deps.get(line1_cid) if line1_cid else None
|
|
2125
|
+
if not line1_wid:
|
|
2126
|
+
continue
|
|
2127
|
+
|
|
2128
|
+
item: dict[str, Any] = {
|
|
2129
|
+
"line1MemberId": line1_wid,
|
|
2130
|
+
"customDisplayName": assignment.get("display_name", ""),
|
|
2131
|
+
}
|
|
2132
|
+
# Optional line 2 (virtual line or second user)
|
|
2133
|
+
line2_cid = assignment.get("line2_canonical_id")
|
|
2134
|
+
if line2_cid:
|
|
2135
|
+
line2_wid = deps.get(line2_cid)
|
|
2136
|
+
if line2_wid:
|
|
2137
|
+
item["line2MemberId"] = line2_wid
|
|
2138
|
+
items.append(item)
|
|
2139
|
+
|
|
2140
|
+
if not items:
|
|
2141
|
+
return []
|
|
2142
|
+
|
|
2143
|
+
# Batch into groups of 50 (Webex bulk handset API limit)
|
|
2144
|
+
url = _url(
|
|
2145
|
+
f"/telephony/config/locations/{loc_wid}/dectNetworks/{dect_network_wid}/handsets/bulk",
|
|
2146
|
+
ctx,
|
|
2147
|
+
)
|
|
2148
|
+
calls: HandlerResult = []
|
|
2149
|
+
batch_size = 50
|
|
2150
|
+
for i in range(0, len(items), batch_size):
|
|
2151
|
+
batch = items[i : i + batch_size]
|
|
2152
|
+
calls.append(("POST", url, {"items": batch}))
|
|
2153
|
+
return calls
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
# HANDLER_REGISTRY — complete with all operation types
|
|
2157
|
+
HANDLER_REGISTRY: dict[tuple[str, str], Any] = {
|
|
2158
|
+
("location", "create"): handle_location_create,
|
|
2159
|
+
("location", "enable_calling"): handle_location_enable_calling,
|
|
2160
|
+
("line_key_template", "create"): handle_line_key_template_create,
|
|
2161
|
+
("trunk", "create"): handle_trunk_create,
|
|
2162
|
+
("route_group", "create"): handle_route_group_create,
|
|
2163
|
+
("route_list", "create"): handle_route_list_create,
|
|
2164
|
+
("route_list", "configure_numbers"): handle_route_list_configure_numbers,
|
|
2165
|
+
("operating_mode", "create"): handle_operating_mode_create,
|
|
2166
|
+
("schedule", "create"): handle_schedule_create,
|
|
2167
|
+
("user", "create"): handle_user_create,
|
|
2168
|
+
("workspace", "create"): handle_workspace_create,
|
|
2169
|
+
("workspace", "assign_number"): handle_workspace_assign_number,
|
|
2170
|
+
("device", "create"): handle_device_create,
|
|
2171
|
+
("device", "create_activation_code"): handle_device_create_activation_code,
|
|
2172
|
+
("dial_plan", "create"): handle_dial_plan_create,
|
|
2173
|
+
("translation_pattern", "create"): handle_translation_pattern_create,
|
|
2174
|
+
("hunt_group", "create"): handle_hunt_group_create,
|
|
2175
|
+
("call_queue", "create"): handle_call_queue_create,
|
|
2176
|
+
("auto_attendant", "create"): handle_auto_attendant_create,
|
|
2177
|
+
# Feature forwarding / holiday / night service (tier 5)
|
|
2178
|
+
("hunt_group", "configure_forwarding"): handle_hunt_group_configure_forwarding,
|
|
2179
|
+
("call_queue", "configure_forwarding"): handle_call_queue_configure_forwarding,
|
|
2180
|
+
("call_queue", "configure_holiday_service"): handle_call_queue_configure_holiday_service,
|
|
2181
|
+
("call_queue", "configure_night_service"): handle_call_queue_configure_night_service,
|
|
2182
|
+
("call_queue", "configure_stranded_calls"): handle_call_queue_configure_stranded_calls,
|
|
2183
|
+
("auto_attendant", "configure_forwarding"): handle_auto_attendant_configure_forwarding,
|
|
2184
|
+
("call_park", "create"): handle_call_park_create,
|
|
2185
|
+
("pickup_group", "create"): handle_pickup_group_create,
|
|
2186
|
+
("paging_group", "create"): handle_paging_group_create,
|
|
2187
|
+
("voicemail_group", "create"): handle_voicemail_group_create,
|
|
2188
|
+
("user", "configure_settings"): handle_user_configure_settings,
|
|
2189
|
+
("user", "configure_voicemail"): handle_user_configure_voicemail,
|
|
2190
|
+
("ecbn_config", "configure"): handle_ecbn_config_configure,
|
|
2191
|
+
("device", "configure_settings"): handle_device_configure_settings,
|
|
2192
|
+
("workspace", "configure_settings"): handle_workspace_configure_settings,
|
|
2193
|
+
("calling_permission", "assign"): handle_calling_permission_assign,
|
|
2194
|
+
("call_forwarding", "configure"): handle_call_forwarding_configure,
|
|
2195
|
+
("single_number_reach", "configure"): handle_snr_configure,
|
|
2196
|
+
# Tier 6
|
|
2197
|
+
("monitoring_list", "configure"): handle_monitoring_list_configure,
|
|
2198
|
+
("receptionist_config", "configure"): handle_receptionist_config_configure,
|
|
2199
|
+
("shared_line", "configure"): handle_shared_line_configure,
|
|
2200
|
+
("virtual_line", "create"): handle_virtual_line_create,
|
|
2201
|
+
("virtual_line", "configure"): handle_virtual_line_configure,
|
|
2202
|
+
# Tier 7
|
|
2203
|
+
("device_layout", "configure"): handle_device_layout_configure,
|
|
2204
|
+
("softkey_config", "configure"): handle_softkey_config_configure,
|
|
2205
|
+
# Device settings templates
|
|
2206
|
+
("device_settings_template", "apply_location_settings"): handle_device_settings_template_apply_location_settings,
|
|
2207
|
+
("device_settings_template", "apply_device_override"): handle_device_settings_template_apply_device_override,
|
|
2208
|
+
# Hoteling / Hot Desking
|
|
2209
|
+
("device_profile", "enable_hoteling_guest"): handle_hoteling_guest_enable,
|
|
2210
|
+
("device_profile", "enable_hoteling_host"): handle_hoteling_host_configure,
|
|
2211
|
+
("hoteling_location", "enable_hotdesking"): handle_location_hotdesking_enable,
|
|
2212
|
+
# Bulk job handlers
|
|
2213
|
+
("bulk_device_settings", "submit"): handle_bulk_device_settings_submit,
|
|
2214
|
+
("bulk_line_key_template", "submit"): handle_bulk_line_key_template_submit,
|
|
2215
|
+
("bulk_dynamic_settings", "submit"): handle_bulk_dynamic_settings_submit,
|
|
2216
|
+
("bulk_rebuild_phones", "submit"): handle_bulk_rebuild_phones_submit,
|
|
2217
|
+
# DECT network provisioning
|
|
2218
|
+
("dect_network", "create"): handle_dect_network_create,
|
|
2219
|
+
("dect_network", "create_base_stations"): handle_dect_base_station_create,
|
|
2220
|
+
("dect_network", "assign_handsets"): handle_dect_handset_assign,
|
|
2221
|
+
# Advisory-to-execution bridge (Phase A: no-op placeholders)
|
|
2222
|
+
("music_on_hold", "configure"): handle_music_on_hold_configure,
|
|
2223
|
+
("announcement", "upload"): handle_announcement_upload,
|
|
2224
|
+
# Executive/assistant pairing (tier 5)
|
|
2225
|
+
("executive_assistant", "assign_executive_type"): handle_executive_type_assign,
|
|
2226
|
+
("executive_assistant", "assign_assistant_type"): handle_assistant_type_assign,
|
|
2227
|
+
("executive_assistant", "assign_assistants"): handle_executive_assign_assistants,
|
|
2228
|
+
("executive_assistant", "configure_alert"): handle_executive_configure_alert,
|
|
2229
|
+
("executive_assistant", "configure_filtering"): handle_executive_configure_filtering,
|
|
2230
|
+
("executive_assistant", "configure_screening"): handle_executive_configure_screening,
|
|
2231
|
+
("executive_assistant", "configure_assistant_settings"): handle_assistant_configure_settings,
|
|
2232
|
+
}
|