Remediation/TeamsPhone/TEL-VOICE-APPS.ps1
|
# UNTESTED: this script has never been run on a real tenant. Read it, try it on a test tenant first, and use it at your own risk. <# .SYNOPSIS Exports the auto attendants and queues of the source, then recreates resource accounts and queues in the target (procedure TEL-VOICE-APPS). .DESCRIPTION Order: full export (configuration and audio files) to <output>\telephony\<tenant>, resource accounts with their license, call queues, then the link of each account to its queue or auto attendant. - Resource account: New-CsOnlineApplicationInstance does not go through Set-CsOnlineApplicationInstance, missing in application mode. The usage location and the license go through Graph, the number through Set-CsPhoneNumberAssignment (TEL-NUMBERS). The UPN comes from the matrix: the final UPN if its domain is in the target, otherwise the temporary UPN. - Call queue: created with its routing, language, thresholds and agents found through the matrix. The default music applies; greetings, overflow and lists are completed in the admin center. - Auto attendant: menus, greetings, hours and transfers aim at objects of the source. An auto attendant is not copied, it is entered in the admin center from the export. decisions\TEL-VOICE-APPS.csv says which exist, which triggers their link. Entra role needed on top of the Teams role: User Administrator, required to create a resource account. #> $c = Open-TcRemediation $AppStandard = 'ce933385-9390-45d1-9512-c8d228074e07' $AppFile = '11cd3e2e-fccb-42ad-ad00-878b93575e07' $Country = (Get-TcRemediationOption 'UsageLocation') $ResourceLicense = Get-TcRemediationTable 'Target' 'licenses' | Where-Object { $_['SKU'] -eq 'PHONESYSTEM_VIRTUALUSER' } | Select-Object -First 1 $ResourceSku = [string](Get-TcProperty $ResourceLicense 'SkuId' '') $TargetDomains = @(Get-TcRemediationTable 'Target' 'domains' | ForEach-Object { ([string]$_['Domain']).ToLowerInvariant() }) function Get-SourceQueue { <# Queues of the source, read once: each change of tenant opens a Teams session again. #> param([string]$Source) if (-not $script:TcRemediationRun['SourceQueues']) { $script:TcRemediationRun['SourceQueues'] = @{} } if (-not $script:TcRemediationRun['SourceQueues'][$Source]) { $script:TcRemediationRun['SourceQueues'][$Source] = @(Invoke-TcServiceCommand -Tenant $Source -Service Teams -Command 'Get-CsCallQueue' -Parameters @{ First = 100 }) } return $script:TcRemediationRun['SourceQueues'][$Source] } function Get-AudioId { <# Identifiers of the audio files found in an exported configuration. #> param($Object) if ($null -eq $Object -or $Object -is [string] -or (Get-TcMember ($Object.GetType()) 'IsValueType')) { return } if ($Object -is [System.Collections.IDictionary]) { foreach ($k in $Object.Keys) { $v = $Object[$k] if ($k -match 'AudioFile(Id|Prompt)$' -and $v -is [string] -and $v) { $v } elseif ($k -eq 'AudioFilePrompt' -and $v -is [System.Collections.IDictionary] -and $v['Id']) { [string]$v['Id'] } else { Get-AudioId $v } } return } foreach ($x in $Object) { Get-AudioId $x } } function Get-TargetAccount { <# UPN of the resource account in the target, from the matrix. #> param([string]$Upn, [hashtable]$Matrix) $l = $Matrix[$Upn.ToLowerInvariant()] if (-not $l) { return '' } if ((Get-TcMember $l 'Target UPN') -and (((Get-TcText $l 'Target UPN') -split '@')[-1]).ToLowerInvariant() -in $TargetDomains) { return (Get-TcText $l 'Target UPN') } return (Get-TcText $l 'Temporary UPN') } function Add-QueueLink { <# Links a resource account to its queue or auto attendant, found by name in the target. #> param([string]$Upn, [string]$Name, [ValidateSet('CallQueue', 'AutoAttendant')][string]$Type) if (-not $Upn) { return } Add-TcBlockAction -Tenant 'Target' -Service Teams -Object $Upn -Description ("Link {0} to {1}" -f $Upn, $Name) -Data @{ Upn = $Upn; Name = $Name; Type = $Type } ` -Test { param($a) $r = Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'Get-CsOnlineApplicationInstance' -Parameters @{ Identity = $a.data.Upn } $a.data.Account = (Get-TcText $r 'ObjectId') try { return [bool](Get-TcMember (Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'Get-CsOnlineApplicationInstanceAssociation' -Parameters @{ Identity = $a.data.Account }) 'ConfigurationId') } catch { return $false } } ` -Block { param($a) $command = if ($a.data.Type -eq 'CallQueue') { 'Get-CsCallQueue' } else { 'Get-CsAutoAttendant' } $cfg = @(Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command $command -Parameters @{ NameFilter = $a.data.Name } | Where-Object { (Get-TcMember $_ 'Name') -eq $a.data.Name }) | Select-Object -First 1 if (-not $cfg) { throw "$($a.data.Name) not found in the target" } Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'New-CsOnlineApplicationInstanceAssociation' -Parameters @{ Identities = @($a.data.Account); ConfigurationId = (Get-TcText $cfg 'Identity'); ConfigurationType = $a.data.Type } | Out-Null $a.undo = @{ command = 'Remove-CsOnlineApplicationInstanceAssociation'; parameters = @{ Identities = @($a.data.Account) } } } } $Links = @{} $proposals = foreach ($t in $c.Tenants) { Get-TcRemediationTable $t 'autoAttendants' | ForEach-Object { [ordered]@{ Tenant = $t; AutoAttendant = $_['Name']; Language = $_['Language']; TimeZone = $_['Time zone']; 'Resource accounts' = $_['Resource accounts']; Proposal = '' } } } $created = @(Get-TcDecision -Name 'TEL-VOICE-APPS' -Proposal @($proposals) -Key 'Tenant', 'Auto attendant' -Choice 'Created', 'Postpone') foreach ($t in $c.Tenants) { Write-TcRemediationTenant $t $accounts = @(Get-TcRemediationTable $t 'resourceAccounts') $autoAttendants = @(Get-TcRemediationTable $t 'autoAttendants') $files = @(Get-TcRemediationTable $t 'callQueues') Write-TcRemediationNote ("{0} auto attendant(s), {1} queue(s), {2} resource account(s)" -f $autoAttendants.Count, $files.Count, $accounts.Count) if (-not ($accounts.Count + $autoAttendants.Count + $files.Count)) { continue } $available = [int](Get-TcProperty $ResourceLicense 'Available' 0) if ($available -lt $accounts.Count) { Write-TcRemediationAlert ("{0} resource account license(s) available for {1} account(s): order the rest (CMP-VOICE-LICENSES)" -f $available, $accounts.Count) } $matrix = @{} foreach ($l in @((Get-TcMember (Get-TcRemediationIdentity $t) 'rows'))) { $matrix[((Get-TcText $l 'Source UPN')).ToLowerInvariant()] = $l } $toCreate = @($accounts | Where-Object { (Get-TcProperty $matrix[([string]$_['UPN']).ToLowerInvariant()] 'Decision') -in 'Create', 'To review' }) if ($toCreate.Count) { Write-TcRemediationAlert ("{0} resource account(s) in the matrix: exclude them from user creation, this script creates them" -f $toCreate.Count) } # Source to target links: the object of the source gives the account or agent in the target $byName = @{}; foreach ($r in $accounts) { $byName[[string]$r['Name']] = $r } $objectToUpn = @{}; foreach ($u in Get-TcRemediationTable $t 'teamsUsers') { $objectToUpn[[string]$u['ObjectId']] = [string]$u['UPN'] } $upnToObject = @{}; foreach ($u in Get-TcRemediationTable 'Target' 'teamsUsers') { $upnToObject[([string]$u['UPN']).ToLowerInvariant()] = [string]$u['ObjectId'] } $Links[$t] = @{ Matrix = $matrix; ObjectToUpn = $objectToUpn; UpnToObject = $upnToObject } $exportFolder = Join-Path (Get-TcRemediationOutput) "telephony\$t" Add-TcBlockAction -Tenant $t -Service Teams -Object 'Export of the voice applications' -Description "Export auto attendants, queues, resource accounts and audio files of $t" -Critical -Data @{ Folder = $exportFolder } ` -Reason 'Reads the source: nothing to undo, the files stay for closure.' ` -Block { param($a) New-Item -ItemType Directory -Force -Path (Join-Path $a.data.Folder 'audio') | Out-Null $aa = @(Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'Get-CsAutoAttendant' -Parameters @{ First = 100 }) $cq = @(Get-SourceQueue -Source $a.tenant) $ra = @(Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'Get-CsOnlineApplicationInstance') $aaText = $aa | ConvertTo-Json -Depth 15 -WarningAction SilentlyContinue $cqText = $cq | ConvertTo-Json -Depth 15 -WarningAction SilentlyContinue Set-Content -LiteralPath (Join-Path $a.data.Folder 'standards.json') -Value $aaText -Encoding utf8 Set-Content -LiteralPath (Join-Path $a.data.Folder 'files.json') -Value $cqText -Encoding utf8 $ra | ConvertTo-Json -Depth 6 -WarningAction SilentlyContinue | Set-Content -LiteralPath (Join-Path $a.data.Folder 'resource-accounts.json') -Encoding utf8 foreach ($e in @(@{ App = 'OrgAutoAttendant'; Text = $aaText }, @{ App = 'HuntGroup'; Text = $cqText })) { if (-not (Get-TcMember $e 'Text')) { continue } foreach ($id in @(Get-AudioId ((Get-TcMember $e 'Text') | ConvertFrom-Json -AsHashtable -Depth 30) | Select-Object -Unique)) { try { $bytes = Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'Export-CsOnlineAudioFile' -Parameters @{ ApplicationId = (Get-TcMember $e 'App'); Identity = $id } [System.IO.File]::WriteAllBytes((Join-Path $a.data.Folder ("audio\{0}-{1}.wav" -f (Get-TcMember $e 'App'), $id)), [byte[]]$bytes) } catch { Write-TcRemediationAlert ("Audio file {0} not exported: {1}" -f $id, (Get-TcErrorText $_)) } } } } foreach ($r in $accounts) { $upn = Get-TargetAccount -Upn ([string]$r['UPN']) -Matrix $matrix if (-not $upn) { Write-TcRemediationAlert ("{0} missing from the matrix: account not planned" -f $r['UPN']); continue } $app = if ($r['Usage'] -eq "Call queue") { $AppFile } else { $AppStandard } Add-TcBlockAction -Tenant 'Target' -Service Teams -Object $upn -Description ("Create the resource account {0} and assign its license" -f $upn) ` -Data @{ Upn = $upn; Name = [string]$r['Name']; Application = $app; Sku = $ResourceSku; Country = $Country } ` -Test { param($a) $o = @((Get-TcMember (Invoke-TcGraph -Tenant $a.tenant -Uri ("v1.0/users?`$filter=userPrincipalName eq '{0}'&`$select=id,assignedLicenses" -f $a.data.Upn)) 'value')) | Select-Object -First 1 return [bool]($o -and @((Get-TcMember $o 'assignedLicenses') | Where-Object { (Get-TcMember $_ 'skuId') -eq $a.data.Sku }).Count) } ` -Block { param($a) $d = $a.data $filterValue = "v1.0/users?`$filter=userPrincipalName eq '{0}'&`$select=id,usageLocation" -f (Get-TcMember $d 'Upn') $o = @((Get-TcMember (Invoke-TcGraph -Tenant $a.tenant -Uri $filterValue) 'value')) | Select-Object -First 1 if (-not $o) { Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'New-CsOnlineApplicationInstance' -Parameters @{ UserPrincipalName = (Get-TcMember $d 'Upn'); ApplicationId = [guid](Get-TcMember $d 'Application'); DisplayName = (Get-TcMember $d 'Name') } | Out-Null $end = (Get-TcRemediationNow).AddMinutes(3) while (-not $o -and (Get-TcRemediationNow) -lt $end) { Wait-TcRemediation -Seconds 10 $o = @((Get-TcMember (Invoke-TcGraph -Tenant $a.tenant -Uri $filterValue) 'value')) | Select-Object -First 1 } if (-not $o) { throw "Account $((Get-TcMember $d 'Upn')) created, but not visible in Entra ID yet: run again." } $a.undo = @{ type = 'Graph'; tenant = $a.tenant; method = 'DELETE'; uri = "v1.0/users/$((Get-TcMember $o 'id'))" } } if (-not (Get-TcMember $o 'usageLocation')) { Invoke-TcGraph -Tenant $a.tenant -Method PATCH -Uri "v1.0/users/$((Get-TcMember $o 'id'))" -Body @{ usageLocation = (Get-TcMember $d 'Country') } | Out-Null } Invoke-TcGraph -Tenant $a.tenant -Method POST -Uri "v1.0/users/$((Get-TcMember $o 'id'))/assignLicense" -Body @{ addLicenses = @(@{ skuId = (Get-TcMember $d 'Sku'); disabledPlans = @() }); removeLicenses = @() } | Out-Null } } foreach ($q in $files) { Add-TcBlockAction -Tenant 'Target' -Service Teams -Object $q['Name'] -Description ("Create the call queue {0}" -f $q['Name']) -Data @{ Source = $t; Name = [string]$q['Name'] } ` -Test { param($a) [bool]@(Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'Get-CsCallQueue' -Parameters @{ NameFilter = $a.data.Name } | Where-Object { (Get-TcMember $_ 'Name') -eq $a.data.Name }).Count } ` -Block { param($a) $s = @(Get-SourceQueue -Source $a.data.Source | Where-Object { (Get-TcMember $_ 'Name') -eq $a.data.Name }) | Select-Object -First 1 if (-not $s) { throw "Queue $($a.data.Name) not found in $($a.data.Source)" } $link = $Links[$a.data.Source] $agents = [System.Collections.Generic.List[guid]]::new() foreach ($x in @((Get-TcMember $s 'Agents'))) { $upn = [string](Get-TcMember $link 'ObjectToUpn')[(Get-TcText $x 'ObjectId')] $l = if ($upn) { (Get-TcMember $link 'Matrix')[$upn.ToLowerInvariant()] } $id = if ($l) { @((Get-TcMember $l 'Target UPN'), (Get-TcMember $l 'Temporary UPN') | Where-Object { $_ } | ForEach-Object { (Get-TcMember $link 'UpnToObject')[([string]$_).ToLowerInvariant()] } | Where-Object { $_ }) | Select-Object -First 1 } if ($id) { $agents.Add([guid]$id) } else { Write-TcRemediationAlert ("Queue {0}: agent {1} missing from the target, to add in the admin center" -f $a.data.Name, (Get-TcMember $x 'ObjectId')) } } $p = @{ Name = (Get-TcMember $s 'Name'); LanguageId = (Get-TcText $s 'LanguageId'); UseDefaultMusicOnHold = $true; RoutingMethod = (Get-TcText $s 'RoutingMethod'); PresenceBasedRouting = [bool](Get-TcMember $s 'PresenceBasedRouting') AllowOptOut = [bool](Get-TcMember $s 'AllowOptOut'); ConferenceMode = [bool](Get-TcMember $s 'ConferenceMode'); AgentAlertTime = [int16](Get-TcMember $s 'AgentAlertTime'); OverflowThreshold = [int16](Get-TcMember $s 'OverflowThreshold'); TimeoutThreshold = [int16](Get-TcMember $s 'TimeoutThreshold') } if ($agents.Count) { $p.Users = @($agents) } $n = Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'New-CsCallQueue' -Parameters $p $a.undo = @{ command = 'Remove-CsCallQueue'; parameters = @{ Identity = (Get-TcText $n 'Identity') } } } foreach ($name in @(([string]$q['Resource accounts']) -split ',\s*' | Where-Object { $_ })) { $r = $byName[$name] if ($r) { Add-QueueLink -Upn (Get-TargetAccount -Upn ([string]$r['UPN']) -Matrix $matrix) -Name ([string]$q['Name']) -Type CallQueue } } } if ($files.Count) { Add-TcManualAction -Tenant 'Target' -Object "Call queue options" -Description "Complete the call queues in the Teams admin center" ` -Instruction @("Complete each queue from $exportFolder\files.json: greetings, music, distribution lists, overflow and timeout.", 'The overflow targets are objects of the source: replace them with those of the target.') } foreach ($s in $autoAttendants) { $standardName = [string]$s['Name'] if (@($created | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'AutoAttendant') -eq $standardName -and (Get-TcMember $_ 'Decision') -eq 'Created' }).Count) { foreach ($name in @(([string]$s['Resource accounts']) -split ',\s*' | Where-Object { $_ })) { $r = $byName[$name] if ($r) { Add-QueueLink -Upn (Get-TargetAccount -Upn ([string]$r['UPN']) -Matrix $matrix) -Name $standardName -Type AutoAttendant } } continue } Add-TcManualAction -Tenant 'Target' -Object $standardName -Description ("Create the auto attendant {0} in the Teams admin center" -f $standardName) ` -Instruction @(("Create {0} with the same name: language {1}, time zone {2} to review, {3} call flow(s), {4} schedule(s)." -f $standardName, $s['Language'], $s['Time zone'], $s["Call flows"], $s['Schedules']), "Specification: $exportFolder\standards.json, audio files in the audio subfolder.", 'Enter Created in decisions\TEL-VOICE-APPS.csv, then run again: the script links the resource account.') } } Invoke-TcRemediationPlan -Verify { param($t) $aa = @(Invoke-TcServiceCommand -Tenant 'Target' -Service Teams -Command 'Get-CsAutoAttendant' -Parameters @{ First = 100 }) $cq = @(Invoke-TcServiceCommand -Tenant 'Target' -Service Teams -Command 'Get-CsCallQueue' -Parameters @{ First = 100 }) $ra = @(Invoke-TcServiceCommand -Tenant 'Target' -Service Teams -Command 'Get-CsOnlineApplicationInstance' | Where-Object { (Get-TcText $_ 'ApplicationId') -in $AppStandard, $AppFile }) New-TcRemediationResult Info ("Target: {0} auto attendant(s), {1} queue(s), {2} voice application resource account(s)" -f $aa.Count, $cq.Count, $ra.Count) foreach ($q in Get-TcRemediationTable $t 'callQueues') { if (@($cq | Where-Object { (Get-TcMember $_ 'Name') -eq $q['Name'] }).Count) { New-TcRemediationResult OK ("queue {0} present" -f $q['Name']) } else { New-TcRemediationResult Blocking ("queue {0} missing from the target" -f $q['Name']) } } $lacking = @(Get-TcRemediationTable $t 'autoAttendants' | Where-Object { $n = $_['Name']; -not @($aa | Where-Object { (Get-TcMember $_ 'Name') -eq $n }).Count }) if ($lacking.Count) { New-TcRemediationResult Warning ("{0} auto attendant(s) still to create" -f $lacking.Count) } else { New-TcRemediationResult OK 'all the auto attendants exist in the target' } } Close-TcRemediation |