Public/Functions/Support/VoiceConfig/Set-TeamsPhoneNumber.ps1

# Module: TeamsFunctions
# Function: Teams User Voice Configuration
# Author: David Eberhardt
# Updated: 13-FEB-2022
# Status: Live

#TODO Monitor Set-CsPhoneNumberAssignment, remove deprecated use once testing has completed (with v22.3)
#TODO For Later: When adding -Location, catch "Contact your operator to change the emergency address for this number." - OperatorConnect numbers cannot be assigned a location - display this error (warning?) instead?

function Set-TeamsPhoneNumber {
  <#
  .SYNOPSIS
    Applies a Phone Number to a User Object or Resource Account
  .DESCRIPTION
    Applies a Microsoft Calling Plans Number OR a Direct Routing Number to a User or Resource Account
  .PARAMETER UserPrincipalName
    Required for Parameterset UserPrincipalName. UserPrincipalName of the Object to be assigned the PhoneNumber.
    This can be a UPN of a User Account (CsOnlineUser Object) or a Resource Account (CsOnlineApplicationInstance Object)
  .PARAMETER Object
    Required for Parameterset Object. CsOnlineUser Object passed to the function to reduce query time.
    This can be a UPN of a User Account (CsOnlineUser Object) or a Resource Account (CsOnlineApplicationInstance Object)
  .PARAMETER PhoneNumber
    A Microsoft Calling Plans Number or a Direct Routing Number
    Requires the Account to be licensed. Able to enable PhoneSystem and the Account for Enterprise Voice
    Required format is E.164 or LineUri, starting with a '+' and 10-15 digits long.
  .PARAMETER Force
    Suppresses confirmation prompt unless -Confirm is used explicitly
    Scavenges Phone Number from all accounts the PhoneNumber is currently assigned to including the current User
  .EXAMPLE
    Set-TeamsPhoneNumber -UserPrincipalName John@domain.com -PhoneNumber +15551234567
    Applies the Phone Number +1 (555) 1234-567 to the Account John@domain.com
  .INPUTS
    System.String
  .OUTPUTS
    System.Void - If called directly
    Boolean - If called by another CmdLet
  .NOTES
    Simple helper function to assign a Phone Number to any User or Resource Account
    Returns boolean result and less communication if called by another function
    Can be used providing either the UserPrincipalName or the already queried CsOnlineUser Object
  .COMPONENT
    VoiceConfiguration
  .FUNCTIONALITY
    Enables a User for Enterprise Voice in order to apply a valid Voice Configuration
  .LINK
    https://github.com/DEberhardt/TeamsFunctions/tree/master/docs/Set-TeamsPhoneNumber.md
  .LINK
    https://github.com/DEberhardt/TeamsFunctions/tree/master/docs/about_VoiceConfiguration.md
  .LINK
    https://github.com/DEberhardt/TeamsFunctions/tree/master/docs/about_UserManagement.md
  .LINK
    https://github.com/DEberhardt/TeamsFunctions/tree/master/docs/about_Supporting_Functions.md
  .LINK
    https://github.com/DEberhardt/TeamsFunctions/tree/master/docs/
  #>


  [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium', DefaultParameterSetName = 'UserPrincipalName')]
  [OutputType([Boolean])]
  param(
    [Parameter(Mandatory, Position = 0, ParameterSetName = 'Object', ValueFromPipeline)]
    [Object[]]$Object,

    [Parameter(Mandatory, Position = 0, ParameterSetName = 'UserPrincipalName', ValueFromPipeline, ValueFromPipelineByPropertyName)]
    [Alias('ObjectId', 'Identity')]
    [string[]]$UserPrincipalName,

    [Parameter(Mandatory, Position = 1, HelpMessage = 'Telephone Number to assign')]
    [AllowNull()]
    [AllowEmptyString()]
    [Alias('Tel', 'Number', 'TelephoneNumber')]
    [string]$PhoneNumber,

    [Parameter(HelpMessage = 'Suppresses confirmation prompt unless -Confirm is used explicitly')]
    [switch]$Force
  ) #param

  begin {
    Show-FunctionStatus -Level Live
    Write-Verbose -Message "[BEGIN ] $($MyInvocation.MyCommand)"

    # Asserting MicrosoftTeams Connection
    if ( -not (Assert-MicrosoftTeamsConnection) ) { break }

    # Setting Preference Variables according to Upstream settings
    if (-not $PSBoundParameters.ContainsKey('Verbose')) { $VerbosePreference = $PSCmdlet.SessionState.PSVariable.GetValue('VerbosePreference') }
    if (-not $PSBoundParameters.ContainsKey('Confirm')) { $ConfirmPreference = $PSCmdlet.SessionState.PSVariable.GetValue('ConfirmPreference') }
    if (-not $PSBoundParameters.ContainsKey('WhatIf')) { $WhatIfPreference = $PSCmdlet.SessionState.PSVariable.GetValue('WhatIfPreference') }
    if (-not $PSBoundParameters.ContainsKey('Debug')) { $DebugPreference = $PSCmdlet.SessionState.PSVariable.GetValue('DebugPreference') } else { $DebugPreference = 'Continue' }
    if ( $PSBoundParameters.ContainsKey('InformationAction')) { $InformationPreference = $PSCmdlet.SessionState.PSVariable.GetValue('InformationAction') } else { $InformationPreference = 'Continue' }
    if ( $PSBoundParameters.ContainsKey('ErrorAction')) { $InformationPreference = $PSCmdlet.SessionState.PSVariable.GetValue('ErrorAction') } else { $ErrorActionPreference = 'Stop' }

    $Stack = Get-PSCallStack
    $Called = ($stack.length -ge 3)

    if ( [String]::IsNullOrEmpty($PhoneNumber) ) {
      $PhoneNumber = $null
    }
    else {
      If ($PhoneNumber -notmatch '^(tel:\+|\+)?([0-9]?[-\s]?(\(?[0-9]{3}\)?)[-\s]?([0-9]{3}[-\s]?[0-9]{4})|[0-9]{8,15})((;ext=)([0-9]{3,8}))?$') {
        throw [System.Management.Automation.ValidationMetadataException] 'Not a valid phone number. Must be 8 to 15 digits long'
      }
    }
    # Preparing Splatting Object
    $parameters = $null
    $Parameters = @{
      'PhoneNumber' = $PhoneNumber
      'Called'      = $Called
      'Force'       = $Force
      'ErrorAction' = $ErrorActionPreference
    }

    #region Worker Functions
    function SetNumber {
      [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
      param(
        [Parameter(Mandatory)]
        [string]$UserPrincipalName,

        [Parameter(Mandatory)]
        [AllowNull()]
        [AllowEmptyString()]
        [string]$PhoneNumber,

        [Parameter(Mandatory)]
        [boolean]$PhoneNumberIsMSNumber,

        [Parameter(Mandatory)]
        [ValidateSet('User', 'ApplicationEndpoint')]
        [string]$UserType
      ) #param

      if ( $null -eq $PhoneNumber -or $PhoneNumber -eq '' ) {
        $E164Number = $LineUri = $null
      }
      else {
        $E164Number = Format-StringForUse $PhoneNumber -As E164
        $LineUri = Format-StringForUse $PhoneNumber -As LineUri
      }

      try {
        #Using Set-CsPhoneNumberAssignment, but if failed, fall back to old functionality
        #TEST Number as an OperatorConnect Number
        <# Replaced with below Get-TeamsPhoneNumber
        $CsPhoneNumberAssignmentParams = @{
          'Identity' = $UserPrincipalName
          'PhoneNumber' = $E164Number
          'PhoneNumberType' = if ($PhoneNumberIsMSNumber) { 'CallingPlan' } else { 'DirectRouting' }
        }
        #>

        $PhoneNumberDetails = Get-TeamsPhoneNumber -PhoneNumber $PhoneNumber
        $CsPhoneNumberAssignmentParams = @{
          'Identity'        = $UserPrincipalName
          'PhoneNumber'     = $E164Number
          'PhoneNumberType' = $PhoneNumberDetails.PhoneNumberType
        }
        if ($PSBoundParameters.ContainsKey('Debug')) {
          " Function: $($MyInvocation.MyCommand.Name) - CsPhoneNumberAssignmentParams:", ($CsPhoneNumberAssignmentParams | Format-Table -AutoSize | Out-String).Trim() | Write-Debug
        }

        if ( [String]::IsNullOrEmpty($PhoneNumber) ) {
          # If phonenumber is empty, this needs to call Remove-CsPhoneNumberAssignment
          # Removal of all Numbers required
          if ($PSCmdlet.ShouldProcess("$UserPrincipalName", 'Remove-CsPhoneNumberAssignment -RemoveAll')) {
            Remove-CsPhoneNumberAssignment -Identity $UserPrincipalName -RemoveAll -ErrorAction STOP
          }
        }
        else {
          # Direct Routing Number
          if ($PSCmdlet.ShouldProcess("$UserPrincipalName", "Set-CsPhoneNumberAssignment -PhoneNumber $E164Number")) {
            Set-CsPhoneNumberAssignment @CsPhoneNumberAssignmentParams -ErrorAction STOP
          }
        }
      }
      catch {
        #Writing error of Set-CsPhoneNumberAssignment to debug stream
        Write-Warning -Message "Assignment with 'Set-CsPhoneNumberAssignment' did not work, trying legacy method!"
        " Function: $($MyInvocation.MyCommand.Name) - Set-CsPhoneNumberAssignment threw exception:", ($($_.Exception.Message) | Out-String).Trim() | Write-Debug
        #Using Set-CsUser and Set-CsOnline(Voice)ApplicationInstance to set the object
        switch ( $UserType ) {
          'User' {
            if ($PhoneNumberIsMSNumber) {
              # Calling Plan Number
              if ($PSCmdlet.ShouldProcess("$UserPrincipalName", "Set-CsUser -Telephonenumber $E164Number")) {
                Set-CsUser -Identity "$UserPrincipalName" -TelephoneNumber $E164Number -ErrorAction STOP
              }
            }
            else {
              # Direct Routing Number
              try {
                if ($PSCmdlet.ShouldProcess("$UserPrincipalName", "Set-CsUser -OnPremLineURI $LineUri")) {
                  Set-CsUser -Identity "$UserPrincipalName" -OnPremLineURI $LineUri -ErrorAction STOP
                }
              }
              catch {
                Write-Verbose -Message "Enablement with 'Set-CsUser -OnPremLineUri' did not work, trying 'Set-CsUser -LineUri'!" -Verbose
                if ($PSCmdlet.ShouldProcess("$UserPrincipalName", "Set-CsUser -LineURI $LineUri")) {
                  Set-CsUser -Identity "$UserPrincipalName" -LineURI $LineUri -ErrorAction STOP
                }
              }
            }
          }
          'ApplicationEndpoint' {
            if ($PhoneNumberIsMSNumber) {
              # Calling Plan Number (VoiceApplicationInstance)
              if ($PSCmdlet.ShouldProcess("$UserPrincipalName", "Set-CsOnlineVoiceApplicationInstance -Telephonenumber $E164Number")) {
                $null = (Set-CsOnlineVoiceApplicationInstance -Identity "$UserPrincipalName" -TelephoneNumber $E164Number -ErrorAction STOP)
              }
            }
            else {
              # Direct Routing Number (ApplicationInstance)
              if ($PSCmdlet.ShouldProcess("$UserPrincipalName", "Set-CsOnlineApplicationInstance -OnPremPhoneNumber $E164Number")) {
                $null = (Set-CsOnlineApplicationInstance -Identity "$UserPrincipalName" -OnpremPhoneNumber $E164Number -Force -ErrorAction STOP)
              }
            }
          }
        }
      }
    }

    function SetPhoneNumber ($UserObject, $UserLicense, $PhoneNumber, $Called, $Force) {
      Write-Verbose -Message "[PROCESS] $($MyInvocation.MyCommand)"
      $Id = $($UserObject.UserPrincipalName)
      #region Validating Object
      # Object Location (OnPrem VS Online)
      if ( $UserObject.InterpretedUserType -match 'OnPrem' ) {
        $Message = "'$Id' is not hosted in Teams!"
        if ($Called) {
          Write-Warning -Message $Message
          #return $false
        }
        else {
          Write-Warning -Message $Message
          #Deactivated as Object is able to be used/enabled even if in Islands mode and Object in Skype!
          #throw [System.InvalidOperationException]::New("$Message")
        }
      }

      #Determining Object Type
      $UserType = switch -regex ( $UserObject.InterpretedUserType ) {
        'User' { 'User' }
        'ApplicationInstance' { 'ApplicationEndpoint' }
        Default { $false }
      }

      if ( -not $UserType ) {
        $Message = "Object '$Id' is not a User or an ApplicationEndpoint!"
        if ($Called) {
          Write-Warning -Message $Message
          return $false
        }
        else {
          throw [System.InvalidOperationException]::New("$Message")
        }
      }
      #endregion

      #region Validating License
      if ( -not $UserLicense.PhoneSystem -and -not $UserLicense.PhoneSystemVirtualUser ) {
        $Message = "'$Id' Enterprise Voice Status: User is not licensed correctly (PhoneSystem required)!"
        if ($Called) {
          Write-Warning -Message $Message
          return $false
        }
        else {
          throw [System.InvalidOperationException]::New("$Message")
        }
        return $(if ($Called) { $false })
      }

      if ( -not [string]$UserLicense.PhoneSystemStatus.contains('Success') ) {
        Write-Information "TRYING: '$Id' - Phone System: Not enabled, trying to enable"
        Set-AzureAdUserLicenseServicePlan -UserPrincipalName $UserObject.UserPrincipalName -Enable MCOEV
        $i = 0
        $iMax = 60
        Write-Information "INFO: User '$Id' - Phone System: Enabled; Waiting for AzureAd to write object ($iMax s)"
        $StatusID1 = 'Azure Active Directory is propagating Object. Please wait'
        $CurrentOperationID1 = 'Waiting for Get-AzureAdUserLicense to return a Result'
        Write-Verbose -Message "$StatusID1 - $CurrentOperationID1"
        do {
          if ($i -gt $iMax) {
            Write-Error -Message "Could not find Object in AzureAD in the last $iMax Seconds" -Category ObjectNotFound -RecommendedAction 'Please verify Object has been created (UserPrincipalName); Continue with Set-TeamsResourceAccount'
            return
          }
          Write-Progress -Id 1 -ParentId 0 -Activity $ActivityID1 -Status $StatusID1 -CurrentOperation $CurrentOperationID1 -SecondsRemaining $($iMax - $i) -PercentComplete (($i * 100) / $iMax)
          Start-Sleep -Milliseconds 1000
          $i++
          $UserLicense = Get-AzureAdUserLicense "$Id"
        }
        while ( -not [string]$UserLicense.PhoneSystemStatus.contains('Success') )
      }
      #endregion

      #region Enterprise Voice
      if ( $UserObject.EnterpriseVoiceEnabled ) {
        $EVenabled = $true
      }
      else {
        Write-Information "TRYING: '$Id' - Enterprise Voice: Not enabled, trying to enable"
        $EVenabled = Enable-TeamsUserForEnterpriseVoice -UserPrincipalName $UserObject.UserPrincipalName
      }
      if ( -not $EVenabled ) {
        $Message = "'$Id' Enterprise Voice: User could not be enabled for Enterprise Voice!"
        if ($Called) {
          Write-Warning -Message $Message
          return $false
        }
        else {
          throw [System.InvalidOperationException]::New("$Message")
        }
      }
      #endregion

      #region Validating Phone Number
      # Querying CurrentPhoneNumber
      try {
        $CurrentPhoneNumber = $CsUser.LineUri
        Write-Verbose -Message "Object '$Id' - Phone Number assigned currently: $CurrentPhoneNumber"
      }
      catch {
        $CurrentPhoneNumber = $null
        Write-Verbose -Message "Object '$Id' - Phone Number assigned currently: NONE"
      }

      if ( [String]::IsNullOrEmpty($PhoneNumber) ) {
        if ($CurrentPhoneNumber) {
          Write-Warning -Message "Object '$Id' - PhoneNumber is NULL or Empty. The Existing Number '$CurrentPhoneNumber' will be removed"
        }
        else {
          Write-Verbose -Message "Object '$Id' - PhoneNumber is NULL or Empty, but no Number is currently assigned. No Action taken"
        }
        $PhoneNumber = $null
        $PhoneNumberIsMSNumber = $false
      }
      else {
        #Number Type
        Write-Verbose -Message "Object '$Id' - Parsing Online Telephone Numbers (validating Number against Microsoft Calling Plan Numbers)"
        $MSNumber = ((Format-StringForUse -InputString "$PhoneNumber" -SpecialChars 'tel:+') -split ';')[0]
        $CsOnlineTelephoneNumber = Get-CsOnlineTelephoneNumber -TelephoneNumber $MSNumber -WarningAction SilentlyContinue
        $PhoneNumberIsMSNumber = if ( $null -ne $CsOnlineTelephoneNumber ) { $true } else { $false }
        Write-Verbose -Message "Provisioning for $(if ( $PhoneNumberIsMSNumber ) { 'Calling Plans' } else { 'Direct Routing'})"

        # Previous assignments
        if ( $PhoneNumber ) {
          $UserWithThisNumber = Find-TeamsUserVoiceConfig -PhoneNumber $PhoneNumber -WarningAction SilentlyContinue
          $UserWithThisNumberExceptSelf = $UserWithThisNumber | Where-Object UserPrincipalName -NE $UserObject.UserPrincipalName
        }
        else {
          $UserWithThisNumber = $UserWithThisNumberExceptSelf = $null
        }
        if ( $UserWithThisNumberExceptSelf ) {
          if ($Force) {
            Write-Warning -Message "Object '$Id' - Number '$PhoneNumber' is currently assigned to User '$($UserWithThisNumber.UserPrincipalName)'. This assignment will be removed!"
          }
          else {
            Write-Error -Message "Object '$Id' - Number '$PhoneNumber' is already assigned to another Object: '$($UserWithThisNumber.UserPrincipalName)'" -Category NotImplemented -RecommendedAction 'Please specify a different Number or use -Force to re-assign' -ErrorAction Stop
          }
        }
      }
      #endregion

      #region ACTION
      # Scavenging Phone Number
      if ( $Force ) {
        Write-Warning -Message 'Parameter Force - Scavenging Phone Number from all Objects where number is assigned. Validate carefully'
        foreach ($UserWTN in $UserWithThisNumberExceptSelf) {
          Write-Verbose -Message "Object '$($UserWTN.UserPrincipalName)' - Scavenging Phone Number"
          try {
            if ( $UserWtn.UserPrincipalName -and -not $UserWtn.InterpretedVoiceConfigType ) {
              $UserWTNObject = Get-TeamsUserVoiceConfig -UserPrincipalName "$($UserWtn.UserPrincipalName)" -WarningAction SilentlyContinue
              $UserWTNPhoneNumberIsMSNumber = $($UserWTNObject.InterpretedVoiceConfigType -eq 'CallingPlans')
              $UserWTNUserType = $UserWTNObject.ObjectType
            }
            else {
              $UserWTNPhoneNumberIsMSNumber = $($UserWtn.InterpretedVoiceConfigType -eq 'CallingPlans')
              $UserWTNUserType = $UserWTN.ObjectType
            }
            $SetNumberParams = @{
              'UserPrincipalName'     = $($UserWTN.UserPrincipalName)
              'PhoneNumber'           = $null
              'PhoneNumberIsMSNumber' = $UserWTNPhoneNumberIsMSNumber
              'UserType'              = $UserWTNUserType
              'ErrorAction'           = $ErrorActionPreference
            }
            SetNumber @SetNumberParams
            Write-Information "INFO: '$($UserWTN.UserPrincipalName)' - Phone Number '$PhoneNumber' removed" -InformationAction Continue
            if ($Called) {
              return $true
            }
          }
          catch {
            $Message = "'$Id' - Error scavenging Phone Number: $($_.Exception.Message)"
            if ($Called) {
              Write-Warning -Message $Message
              return $false
            }
            else {
              throw $_
            }
          }

        }
      }

      #Removing Phone Number
      if ( $Force -or ([String]::IsNullOrEmpty($PhoneNumber)) ) {
        Write-Verbose -Message "Object '$Id' - Removing Phone Number"
        try {
          $SetNumberParams = @{
            'UserPrincipalName'     = $Id
            'PhoneNumber'           = $null
            'PhoneNumberIsMSNumber' = $PhoneNumberIsMSNumber
            'UserType'              = $UserType
            'ErrorAction'           = $ErrorActionPreference
          }
          SetNumber @SetNumberParams
          if ($Called) {
            return $true
          }
        }
        catch {
          $Message = "'$Id' - Error removing Phone Number: $($_.Exception.Message)"
          if ($Called) {
            Write-Warning -Message $Message
            return $false
          }
          else {
            throw $_
          }
        }
      }

      #Setting Phone Number
      if ( -not ([String]::IsNullOrEmpty($PhoneNumber)) ) {
        Write-Verbose -Message "Object '$Id' - Applying Phone Number"
        try {
          $SetNumberParams = @{
            'UserPrincipalName'     = $Id
            'PhoneNumber'           = $PhoneNumber
            'PhoneNumberIsMSNumber' = $PhoneNumberIsMSNumber
            'UserType'              = $UserType
            'ErrorAction'           = $ErrorActionPreference
          }
          SetNumber @SetNumberParams
          if ($Called) {
            return $true
          }
        }
        catch {
          $Message = "'$Id' - Error applying Phone Number: $($_.Exception.Message)"
          if ($Called) {
            Write-Warning -Message $Message
            return $false
          }
          else {
            throw $_
          }
        }
      }
      #endregion

      # Output
      if ($Called) {
        Write-Output $true
      }
    }
    #endregion
  } #begin

  process {
    Write-Verbose -Message "[PROCESS] $($MyInvocation.MyCommand)"
    switch ($PSCmdlet.ParameterSetName) {
      'UserprincipalName' {
        foreach ($User in $UserPrincipalName) {
          Write-Verbose -Message "[PROCESS] Processing '$User'"
          try {
            #NOTE Call placed without the Identity Switch to make remoting call and receive object in tested format (v2.5.0 and higher)
            #$CsUser = Get-CsOnlineUser -Identity "$User" -WarningAction SilentlyContinue -ErrorAction Stop
            $CsUser = Get-CsOnlineUser "$User" -WarningAction SilentlyContinue -ErrorAction Stop
            $UserLicense = Get-AzureAdUserLicense "$User"
          }
          catch {
            Write-Error "'$User' not found" -Category ObjectNotFound
            continue
          }
          SetPhoneNumber -UserObject $CsUser -UserLicense $UserLicense @Parameters
        }
      }
      'Object' {
        foreach ($O in $Object) {
          Write-Verbose -Message "[PROCESS] Processing provided CsOnlineUser Object for '$($O.UserPrincipalName)'"
          $UserLicense = Get-AzureAdUserLicense "$($O.UserPrincipalName)"
          SetPhoneNumber -UserObject $O -UserLicense $UserLicense @Parameters
        }
      }
    }
  } #process

  end {
    Write-Verbose -Message "[END ] $($MyInvocation.MyCommand)"
  } #end
} #Set-TeamsPhoneNumber