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.
Files changed (413) hide show
  1. wxcli/__init__.py +4 -0
  2. wxcli/_playbook/.claude/agents/migration-advisor.md +239 -0
  3. wxcli/_playbook/.claude/agents/wxc-calling-builder.md +946 -0
  4. wxcli/_playbook/.claude/rules/cleanup.md +29 -0
  5. wxcli/_playbook/.claude/rules/cucm-migration.md +58 -0
  6. wxcli/_playbook/.claude/rules/org-health.md +14 -0
  7. wxcli/_playbook/.claude/settings.json +8 -0
  8. wxcli/_playbook/.claude/skills/audit-compliance/SKILL.md +515 -0
  9. wxcli/_playbook/.claude/skills/call-control/SKILL.md +747 -0
  10. wxcli/_playbook/.claude/skills/configure-features/SKILL.md +623 -0
  11. wxcli/_playbook/.claude/skills/configure-routing/SKILL.md +627 -0
  12. wxcli/_playbook/.claude/skills/contact-center/SKILL.md +1181 -0
  13. wxcli/_playbook/.claude/skills/cucm-migrate/SKILL.md +820 -0
  14. wxcli/_playbook/.claude/skills/customer-assist/SKILL.md +443 -0
  15. wxcli/_playbook/.claude/skills/device-platform/SKILL.md +407 -0
  16. wxcli/_playbook/.claude/skills/manage-call-settings/SKILL.md +757 -0
  17. wxcli/_playbook/.claude/skills/manage-devices/SKILL.md +790 -0
  18. wxcli/_playbook/.claude/skills/manage-identity/SKILL.md +638 -0
  19. wxcli/_playbook/.claude/skills/manage-licensing/SKILL.md +528 -0
  20. wxcli/_playbook/.claude/skills/manage-meetings/SKILL.md +708 -0
  21. wxcli/_playbook/.claude/skills/messaging-bots/SKILL.md +365 -0
  22. wxcli/_playbook/.claude/skills/messaging-spaces/SKILL.md +520 -0
  23. wxcli/_playbook/.claude/skills/org-health/SKILL.md +159 -0
  24. wxcli/_playbook/.claude/skills/provision-calling/SKILL.md +421 -0
  25. wxcli/_playbook/.claude/skills/query-live/SKILL.md +183 -0
  26. wxcli/_playbook/.claude/skills/query-live/domains/call-flow-trace.md +223 -0
  27. wxcli/_playbook/.claude/skills/query-live/domains/call-history.md +44 -0
  28. wxcli/_playbook/.claude/skills/query-live/domains/features.md +169 -0
  29. wxcli/_playbook/.claude/skills/query-live/domains/numbers.md +93 -0
  30. wxcli/_playbook/.claude/skills/query-live/domains/people-and-settings.md +146 -0
  31. wxcli/_playbook/.claude/skills/query-live/domains/routing.md +124 -0
  32. wxcli/_playbook/.claude/skills/reporting/SKILL.md +501 -0
  33. wxcli/_playbook/.claude/skills/reporting/references/cdr-recipes.md +1628 -0
  34. wxcli/_playbook/.claude/skills/reporting-cc/SKILL.md +251 -0
  35. wxcli/_playbook/.claude/skills/reporting-meetings/SKILL.md +290 -0
  36. wxcli/_playbook/.claude/skills/teardown/SKILL.md +214 -0
  37. wxcli/_playbook/.claude/skills/video-mesh/SKILL.md +379 -0
  38. wxcli/_playbook/.claude/skills/wxc-calling-debug/SKILL.md +475 -0
  39. wxcli/_playbook/CLAUDE.md +373 -0
  40. wxcli/_playbook/docs/reference/CLAUDE.md +25 -0
  41. wxcli/_playbook/docs/reference/admin-apps-data.md +643 -0
  42. wxcli/_playbook/docs/reference/admin-audit-security.md +322 -0
  43. wxcli/_playbook/docs/reference/admin-hybrid.md +383 -0
  44. wxcli/_playbook/docs/reference/admin-identity-scim.md +1007 -0
  45. wxcli/_playbook/docs/reference/admin-licensing.md +414 -0
  46. wxcli/_playbook/docs/reference/admin-org-management.md +586 -0
  47. wxcli/_playbook/docs/reference/admin-partner.md +357 -0
  48. wxcli/_playbook/docs/reference/archive/wxc-sdk-patterns.md +1229 -0
  49. wxcli/_playbook/docs/reference/archive/wxcadm-advanced.md +835 -0
  50. wxcli/_playbook/docs/reference/archive/wxcadm-core.md +743 -0
  51. wxcli/_playbook/docs/reference/archive/wxcadm-devices-workspaces.md +1305 -0
  52. wxcli/_playbook/docs/reference/archive/wxcadm-features.md +849 -0
  53. wxcli/_playbook/docs/reference/archive/wxcadm-locations.md +938 -0
  54. wxcli/_playbook/docs/reference/archive/wxcadm-person.md +1255 -0
  55. wxcli/_playbook/docs/reference/archive/wxcadm-routing.md +1262 -0
  56. wxcli/_playbook/docs/reference/authentication.md +965 -0
  57. wxcli/_playbook/docs/reference/call-control.md +1205 -0
  58. wxcli/_playbook/docs/reference/call-features-additional.md +2541 -0
  59. wxcli/_playbook/docs/reference/call-features-major.md +1320 -0
  60. wxcli/_playbook/docs/reference/call-routing.md +2304 -0
  61. wxcli/_playbook/docs/reference/contact-center-analytics.md +1049 -0
  62. wxcli/_playbook/docs/reference/contact-center-core.md +1614 -0
  63. wxcli/_playbook/docs/reference/contact-center-journey.md +457 -0
  64. wxcli/_playbook/docs/reference/contact-center-routing.md +1138 -0
  65. wxcli/_playbook/docs/reference/devices-core.md +1869 -0
  66. wxcli/_playbook/docs/reference/devices-dect.md +1150 -0
  67. wxcli/_playbook/docs/reference/devices-platform.md +467 -0
  68. wxcli/_playbook/docs/reference/devices-workspaces.md +1426 -0
  69. wxcli/_playbook/docs/reference/emergency-services.md +1122 -0
  70. wxcli/_playbook/docs/reference/location-calling-core.md +2037 -0
  71. wxcli/_playbook/docs/reference/location-calling-media.md +1334 -0
  72. wxcli/_playbook/docs/reference/location-recording-advanced.md +1482 -0
  73. wxcli/_playbook/docs/reference/meetings-content.md +473 -0
  74. wxcli/_playbook/docs/reference/meetings-core.md +903 -0
  75. wxcli/_playbook/docs/reference/meetings-infrastructure.md +899 -0
  76. wxcli/_playbook/docs/reference/meetings-settings.md +850 -0
  77. wxcli/_playbook/docs/reference/messaging-bots.md +1091 -0
  78. wxcli/_playbook/docs/reference/messaging-spaces.md +695 -0
  79. wxcli/_playbook/docs/reference/person-call-settings-behavior.md +1639 -0
  80. wxcli/_playbook/docs/reference/person-call-settings-handling.md +1394 -0
  81. wxcli/_playbook/docs/reference/person-call-settings-media.md +1898 -0
  82. wxcli/_playbook/docs/reference/person-call-settings-permissions.md +1329 -0
  83. wxcli/_playbook/docs/reference/provisioning.md +1366 -0
  84. wxcli/_playbook/docs/reference/reporting-analytics.md +1318 -0
  85. wxcli/_playbook/docs/reference/self-service-call-settings.md +1020 -0
  86. wxcli/_playbook/docs/reference/virtual-lines.md +1132 -0
  87. wxcli/_playbook/docs/reference/webhooks-events.md +1444 -0
  88. wxcli/_playbook/docs/reference/wxcadm-xsi-realtime.md +953 -0
  89. wxcli/_version.py +24 -0
  90. wxcli/auth.py +137 -0
  91. wxcli/commands/__init__.py +0 -0
  92. wxcli/commands/_registry.py +181 -0
  93. wxcli/commands/activation_email.py +103 -0
  94. wxcli/commands/admin_recordings.py +585 -0
  95. wxcli/commands/analytics.py +105 -0
  96. wxcli/commands/announcement_playlists.py +219 -0
  97. wxcli/commands/announcements.py +519 -0
  98. wxcli/commands/archive_users.py +36 -0
  99. wxcli/commands/attachment_actions.py +60 -0
  100. wxcli/commands/audit_events.py +83 -0
  101. wxcli/commands/authorizations.py +113 -0
  102. wxcli/commands/auto_attendant.py +642 -0
  103. wxcli/commands/broadworks_billing_reports.py +126 -0
  104. wxcli/commands/broadworks_enterprises.py +160 -0
  105. wxcli/commands/broadworks_subscribers.py +319 -0
  106. wxcli/commands/broadworks_workspaces.py +108 -0
  107. wxcli/commands/call_controls.py +1304 -0
  108. wxcli/commands/call_park.py +494 -0
  109. wxcli/commands/call_pickup.py +229 -0
  110. wxcli/commands/call_queue.py +1878 -0
  111. wxcli/commands/call_recording.py +707 -0
  112. wxcli/commands/call_routing.py +1610 -0
  113. wxcli/commands/call_settings_for_me_phase_5.py +212 -0
  114. wxcli/commands/caller_reputation.py +169 -0
  115. wxcli/commands/calling_service.py +305 -0
  116. wxcli/commands/cc_address_book.py +770 -0
  117. wxcli/commands/cc_agent_greetings.py +242 -0
  118. wxcli/commands/cc_agent_summaries.py +39 -0
  119. wxcli/commands/cc_agent_wellbeing.py +216 -0
  120. wxcli/commands/cc_agents.py +425 -0
  121. wxcli/commands/cc_ai_assistant.py +55 -0
  122. wxcli/commands/cc_ai_feature.py +294 -0
  123. wxcli/commands/cc_audio_files.py +149 -0
  124. wxcli/commands/cc_auto_csat.py +311 -0
  125. wxcli/commands/cc_aux_code.py +458 -0
  126. wxcli/commands/cc_business_hour.py +353 -0
  127. wxcli/commands/cc_call_monitoring.py +228 -0
  128. wxcli/commands/cc_callbacks.py +194 -0
  129. wxcli/commands/cc_campaign.py +212 -0
  130. wxcli/commands/cc_captures.py +39 -0
  131. wxcli/commands/cc_contact_list.py +169 -0
  132. wxcli/commands/cc_contact_number.py +295 -0
  133. wxcli/commands/cc_data_sources.py +235 -0
  134. wxcli/commands/cc_desktop_layout.py +380 -0
  135. wxcli/commands/cc_desktop_profile.py +322 -0
  136. wxcli/commands/cc_dial_number.py +511 -0
  137. wxcli/commands/cc_dial_plan.py +347 -0
  138. wxcli/commands/cc_dnc.py +101 -0
  139. wxcli/commands/cc_entry_point.py +545 -0
  140. wxcli/commands/cc_ewt.py +51 -0
  141. wxcli/commands/cc_flow.py +477 -0
  142. wxcli/commands/cc_global_vars.py +430 -0
  143. wxcli/commands/cc_holiday_list.py +335 -0
  144. wxcli/commands/cc_journey.py +1261 -0
  145. wxcli/commands/cc_legacy_flows.py +49 -0
  146. wxcli/commands/cc_multimedia_profile.py +436 -0
  147. wxcli/commands/cc_notification.py +51 -0
  148. wxcli/commands/cc_outdial_ani.py +604 -0
  149. wxcli/commands/cc_overrides.py +341 -0
  150. wxcli/commands/cc_queue.py +1083 -0
  151. wxcli/commands/cc_queue_stats.py +55 -0
  152. wxcli/commands/cc_realtime.py +51 -0
  153. wxcli/commands/cc_resource_collection.py +312 -0
  154. wxcli/commands/cc_search.py +46 -0
  155. wxcli/commands/cc_site.py +400 -0
  156. wxcli/commands/cc_skill.py +350 -0
  157. wxcli/commands/cc_skill_profile.py +289 -0
  158. wxcli/commands/cc_subscriptions.py +426 -0
  159. wxcli/commands/cc_summaries.py +130 -0
  160. wxcli/commands/cc_tasks.py +844 -0
  161. wxcli/commands/cc_team.py +325 -0
  162. wxcli/commands/cc_user_profiles.py +349 -0
  163. wxcli/commands/cc_users.py +701 -0
  164. wxcli/commands/cc_work_types.py +363 -0
  165. wxcli/commands/cdr.py +89 -0
  166. wxcli/commands/classifications.py +38 -0
  167. wxcli/commands/cleanup.py +1427 -0
  168. wxcli/commands/client_settings.py +72 -0
  169. wxcli/commands/conference.py +328 -0
  170. wxcli/commands/configure.py +72 -0
  171. wxcli/commands/converged_recordings.py +347 -0
  172. wxcli/commands/converged_recordings_export.py +310 -0
  173. wxcli/commands/cq_playlists.py +42 -0
  174. wxcli/commands/cucm.py +2794 -0
  175. wxcli/commands/cucm_config.py +129 -0
  176. wxcli/commands/customer_assist.py +373 -0
  177. wxcli/commands/data_sources.py +227 -0
  178. wxcli/commands/dect_devices.py +787 -0
  179. wxcli/commands/device_configurations.py +77 -0
  180. wxcli/commands/device_dynamic_settings.py +320 -0
  181. wxcli/commands/device_settings.py +1489 -0
  182. wxcli/commands/devices.py +280 -0
  183. wxcli/commands/domains.py +142 -0
  184. wxcli/commands/ecm.py +178 -0
  185. wxcli/commands/emergency_services.py +827 -0
  186. wxcli/commands/events.py +83 -0
  187. wxcli/commands/external_voicemail.py +52 -0
  188. wxcli/commands/groups.py +206 -0
  189. wxcli/commands/guest_management.py +76 -0
  190. wxcli/commands/hds.py +340 -0
  191. wxcli/commands/hot_desk.py +67 -0
  192. wxcli/commands/hot_desking_members.py +118 -0
  193. wxcli/commands/hot_desking_portal.py +124 -0
  194. wxcli/commands/hunt_group.py +577 -0
  195. wxcli/commands/hybrid_clusters.py +71 -0
  196. wxcli/commands/hybrid_connectors.py +67 -0
  197. wxcli/commands/identity_org.py +159 -0
  198. wxcli/commands/init_playbook.py +155 -0
  199. wxcli/commands/licenses.py +106 -0
  200. wxcli/commands/live_monitoring.py +40 -0
  201. wxcli/commands/location_call_handling.py +577 -0
  202. wxcli/commands/location_schedules.py +341 -0
  203. wxcli/commands/location_settings.py +1297 -0
  204. wxcli/commands/location_voicemail.py +494 -0
  205. wxcli/commands/locations.py +336 -0
  206. wxcli/commands/meeting_captions.py +110 -0
  207. wxcli/commands/meeting_chats.py +63 -0
  208. wxcli/commands/meeting_invitees.py +234 -0
  209. wxcli/commands/meeting_messages.py +28 -0
  210. wxcli/commands/meeting_participants.py +278 -0
  211. wxcli/commands/meeting_polls.py +111 -0
  212. wxcli/commands/meeting_preferences.py +515 -0
  213. wxcli/commands/meeting_qa.py +78 -0
  214. wxcli/commands/meeting_qualities.py +43 -0
  215. wxcli/commands/meeting_reports.py +101 -0
  216. wxcli/commands/meeting_session_types.py +105 -0
  217. wxcli/commands/meeting_site.py +57 -0
  218. wxcli/commands/meeting_slido.py +44 -0
  219. wxcli/commands/meeting_summaries.py +92 -0
  220. wxcli/commands/meeting_tracking_codes.py +233 -0
  221. wxcli/commands/meeting_transcripts.py +230 -0
  222. wxcli/commands/meetings.py +1997 -0
  223. wxcli/commands/memberships.py +164 -0
  224. wxcli/commands/messages.py +213 -0
  225. wxcli/commands/mode_management.py +244 -0
  226. wxcli/commands/my_call_settings.py +3577 -0
  227. wxcli/commands/numbers.py +405 -0
  228. wxcli/commands/operating_modes.py +410 -0
  229. wxcli/commands/org_contacts.py +284 -0
  230. wxcli/commands/org_health_cli.py +55 -0
  231. wxcli/commands/org_settings.py +71 -0
  232. wxcli/commands/organizations.py +83 -0
  233. wxcli/commands/paging_group.py +257 -0
  234. wxcli/commands/partner_admins.py +141 -0
  235. wxcli/commands/partner_reports.py +188 -0
  236. wxcli/commands/partner_tags.py +216 -0
  237. wxcli/commands/people.py +279 -0
  238. wxcli/commands/person_call_settings.py +71 -0
  239. wxcli/commands/pstn.py +280 -0
  240. wxcli/commands/recording_report.py +154 -0
  241. wxcli/commands/report_templates.py +38 -0
  242. wxcli/commands/reports.py +144 -0
  243. wxcli/commands/resource_group_memberships.py +160 -0
  244. wxcli/commands/resource_groups.py +67 -0
  245. wxcli/commands/roles.py +63 -0
  246. wxcli/commands/room_tabs.py +160 -0
  247. wxcli/commands/rooms.py +225 -0
  248. wxcli/commands/scim_bulk.py +46 -0
  249. wxcli/commands/scim_groups.py +246 -0
  250. wxcli/commands/scim_schemas.py +82 -0
  251. wxcli/commands/scim_users.py +283 -0
  252. wxcli/commands/security_audit.py +54 -0
  253. wxcli/commands/service_apps.py +51 -0
  254. wxcli/commands/single_number_reach.py +209 -0
  255. wxcli/commands/team_memberships.py +155 -0
  256. wxcli/commands/teams.py +153 -0
  257. wxcli/commands/ucm_profile.py +42 -0
  258. wxcli/commands/update.py +219 -0
  259. wxcli/commands/user_settings.py +5116 -0
  260. wxcli/commands/video_mesh.py +1090 -0
  261. wxcli/commands/virtual_extensions.py +502 -0
  262. wxcli/commands/virtual_line_settings.py +2072 -0
  263. wxcli/commands/webhooks.py +176 -0
  264. wxcli/commands/wholesale_billing_reports.py +147 -0
  265. wxcli/commands/wholesale_provisioning.py +523 -0
  266. wxcli/commands/workspace_locations.py +339 -0
  267. wxcli/commands/workspace_metrics.py +103 -0
  268. wxcli/commands/workspace_personalization.py +70 -0
  269. wxcli/commands/workspace_settings.py +3347 -0
  270. wxcli/commands/workspaces.py +279 -0
  271. wxcli/commands/xapi.py +114 -0
  272. wxcli/config.py +139 -0
  273. wxcli/errors.py +77 -0
  274. wxcli/main.py +210 -0
  275. wxcli/migration/CLAUDE.md +106 -0
  276. wxcli/migration/__init__.py +1 -0
  277. wxcli/migration/advisory/CLAUDE.md +196 -0
  278. wxcli/migration/advisory/__init__.py +46 -0
  279. wxcli/migration/advisory/advisor.py +107 -0
  280. wxcli/migration/advisory/advisory_patterns.py +2552 -0
  281. wxcli/migration/advisory/recommendation_rules.py +835 -0
  282. wxcli/migration/cucm/CLAUDE.md +74 -0
  283. wxcli/migration/cucm/__init__.py +5 -0
  284. wxcli/migration/cucm/connection.py +265 -0
  285. wxcli/migration/cucm/discovery.py +225 -0
  286. wxcli/migration/cucm/extractors/__init__.py +1 -0
  287. wxcli/migration/cucm/extractors/announcements.py +92 -0
  288. wxcli/migration/cucm/extractors/base.py +90 -0
  289. wxcli/migration/cucm/extractors/device_profiles.py +98 -0
  290. wxcli/migration/cucm/extractors/devices.py +193 -0
  291. wxcli/migration/cucm/extractors/e911.py +103 -0
  292. wxcli/migration/cucm/extractors/features.py +309 -0
  293. wxcli/migration/cucm/extractors/helpers.py +73 -0
  294. wxcli/migration/cucm/extractors/informational.py +295 -0
  295. wxcli/migration/cucm/extractors/locations.py +221 -0
  296. wxcli/migration/cucm/extractors/moh.py +100 -0
  297. wxcli/migration/cucm/extractors/remote_destinations.py +96 -0
  298. wxcli/migration/cucm/extractors/routing.py +493 -0
  299. wxcli/migration/cucm/extractors/shared_lines.py +146 -0
  300. wxcli/migration/cucm/extractors/templates.py +229 -0
  301. wxcli/migration/cucm/extractors/tier4.py +173 -0
  302. wxcli/migration/cucm/extractors/users.py +240 -0
  303. wxcli/migration/cucm/extractors/voicemail.py +243 -0
  304. wxcli/migration/cucm/extractors/workspaces.py +88 -0
  305. wxcli/migration/cucm/unity_connection.py +304 -0
  306. wxcli/migration/execute/CLAUDE.md +372 -0
  307. wxcli/migration/execute/__init__.py +303 -0
  308. wxcli/migration/execute/batch.py +277 -0
  309. wxcli/migration/execute/dependency.py +483 -0
  310. wxcli/migration/execute/engine.py +964 -0
  311. wxcli/migration/execute/handlers.py +2232 -0
  312. wxcli/migration/execute/planner.py +1939 -0
  313. wxcli/migration/execute/runtime.py +571 -0
  314. wxcli/migration/export/CLAUDE.md +49 -0
  315. wxcli/migration/export/__init__.py +8 -0
  316. wxcli/migration/export/csv_export.py +126 -0
  317. wxcli/migration/export/deployment_plan.py +479 -0
  318. wxcli/migration/export/json_export.py +60 -0
  319. wxcli/migration/models.py +941 -0
  320. wxcli/migration/phone_models.py +50 -0
  321. wxcli/migration/preflight/CLAUDE.md +52 -0
  322. wxcli/migration/preflight/__init__.py +89 -0
  323. wxcli/migration/preflight/checks.py +911 -0
  324. wxcli/migration/preflight/runner.py +293 -0
  325. wxcli/migration/rate_limiter.py +144 -0
  326. wxcli/migration/report/CLAUDE.md +245 -0
  327. wxcli/migration/report/__init__.py +7 -0
  328. wxcli/migration/report/appendix.py +2437 -0
  329. wxcli/migration/report/assembler.py +183 -0
  330. wxcli/migration/report/charts.py +263 -0
  331. wxcli/migration/report/executive.py +776 -0
  332. wxcli/migration/report/explainer.py +704 -0
  333. wxcli/migration/report/helpers.py +74 -0
  334. wxcli/migration/report/ingest.py +152 -0
  335. wxcli/migration/report/notice_templates.py +192 -0
  336. wxcli/migration/report/score.py +375 -0
  337. wxcli/migration/report/styles.py +1052 -0
  338. wxcli/migration/report/user_diff.py +1052 -0
  339. wxcli/migration/report/user_notice.py +425 -0
  340. wxcli/migration/state.py +139 -0
  341. wxcli/migration/store.py +860 -0
  342. wxcli/migration/transform/CLAUDE.md +107 -0
  343. wxcli/migration/transform/__init__.py +12 -0
  344. wxcli/migration/transform/analysis_pipeline.py +453 -0
  345. wxcli/migration/transform/analyzers/__init__.py +142 -0
  346. wxcli/migration/transform/analyzers/css_permission.py +189 -0
  347. wxcli/migration/transform/analyzers/css_routing.py +335 -0
  348. wxcli/migration/transform/analyzers/device_compatibility.py +122 -0
  349. wxcli/migration/transform/analyzers/dn_ambiguity.py +131 -0
  350. wxcli/migration/transform/analyzers/duplicate_user.py +221 -0
  351. wxcli/migration/transform/analyzers/extension_conflict.py +173 -0
  352. wxcli/migration/transform/analyzers/feature_approximation.py +227 -0
  353. wxcli/migration/transform/analyzers/layout_overflow.py +174 -0
  354. wxcli/migration/transform/analyzers/location_ambiguity.py +184 -0
  355. wxcli/migration/transform/analyzers/missing_data.py +300 -0
  356. wxcli/migration/transform/analyzers/selective_call_handling.py +394 -0
  357. wxcli/migration/transform/analyzers/shared_line.py +170 -0
  358. wxcli/migration/transform/analyzers/voicemail_compatibility.py +267 -0
  359. wxcli/migration/transform/analyzers/workspace_license.py +212 -0
  360. wxcli/migration/transform/cross_reference.py +1136 -0
  361. wxcli/migration/transform/cucm_pattern.py +246 -0
  362. wxcli/migration/transform/decisions.py +301 -0
  363. wxcli/migration/transform/e164.py +145 -0
  364. wxcli/migration/transform/engine.py +256 -0
  365. wxcli/migration/transform/mappers/CLAUDE.md +123 -0
  366. wxcli/migration/transform/mappers/__init__.py +76 -0
  367. wxcli/migration/transform/mappers/announcement_mapper.py +122 -0
  368. wxcli/migration/transform/mappers/base.py +190 -0
  369. wxcli/migration/transform/mappers/button_template_mapper.py +186 -0
  370. wxcli/migration/transform/mappers/call_forwarding_mapper.py +228 -0
  371. wxcli/migration/transform/mappers/call_settings_mapper.py +144 -0
  372. wxcli/migration/transform/mappers/css_mapper.py +868 -0
  373. wxcli/migration/transform/mappers/dect_mapper.py +367 -0
  374. wxcli/migration/transform/mappers/device_layout_mapper.py +289 -0
  375. wxcli/migration/transform/mappers/device_mapper.py +227 -0
  376. wxcli/migration/transform/mappers/device_profile_mapper.py +190 -0
  377. wxcli/migration/transform/mappers/device_settings_mapper.py +464 -0
  378. wxcli/migration/transform/mappers/e911_mapper.py +129 -0
  379. wxcli/migration/transform/mappers/ecbn_mapper.py +263 -0
  380. wxcli/migration/transform/mappers/executive_assistant_mapper.py +156 -0
  381. wxcli/migration/transform/mappers/feature_mapper.py +1179 -0
  382. wxcli/migration/transform/mappers/line_mapper.py +310 -0
  383. wxcli/migration/transform/mappers/location_mapper.py +235 -0
  384. wxcli/migration/transform/mappers/moh_mapper.py +114 -0
  385. wxcli/migration/transform/mappers/monitoring_mapper.py +221 -0
  386. wxcli/migration/transform/mappers/receptionist_mapper.py +245 -0
  387. wxcli/migration/transform/mappers/routing_mapper.py +821 -0
  388. wxcli/migration/transform/mappers/snr_mapper.py +201 -0
  389. wxcli/migration/transform/mappers/softkey_mapper.py +205 -0
  390. wxcli/migration/transform/mappers/user_mapper.py +264 -0
  391. wxcli/migration/transform/mappers/voicemail_group_mapper.py +286 -0
  392. wxcli/migration/transform/mappers/voicemail_mapper.py +514 -0
  393. wxcli/migration/transform/mappers/workspace_mapper.py +464 -0
  394. wxcli/migration/transform/normalizers.py +2008 -0
  395. wxcli/migration/transform/pattern_converter.py +106 -0
  396. wxcli/migration/transform/pipeline.py +271 -0
  397. wxcli/migration/transform/rules.py +255 -0
  398. wxcli/org_health/CLAUDE.md +63 -0
  399. wxcli/org_health/__init__.py +7 -0
  400. wxcli/org_health/analyze.py +93 -0
  401. wxcli/org_health/checks.py +458 -0
  402. wxcli/org_health/collector.py +62 -0
  403. wxcli/org_health/models.py +78 -0
  404. wxcli/org_health/report.py +300 -0
  405. wxcli/output.py +101 -0
  406. wxcli-1.2.0.dist-info/METADATA +505 -0
  407. wxcli-1.2.0.dist-info/RECORD +413 -0
  408. wxcli-1.2.0.dist-info/WHEEL +5 -0
  409. wxcli-1.2.0.dist-info/entry_points.txt +2 -0
  410. wxcli-1.2.0.dist-info/licenses/LICENSE +191 -0
  411. wxcli-1.2.0.dist-info/scm_file_list.json +751 -0
  412. wxcli-1.2.0.dist-info/scm_version.json +8 -0
  413. 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
+ }