internal/Get-MtAIAgentInfo.ps1

<#
.SYNOPSIS
    Retrieves Copilot Studio agent information via the Dataverse API.

.DESCRIPTION
    Queries the Dataverse OData API to retrieve configuration and security details
    for Copilot Studio agents, including their topics and tools. Requires an active
    Dataverse connection established via Connect-Maester -Service Dataverse.

    The Dataverse environment is resolved at connect time (either auto-discovered
    via the Global Discovery Service or explicitly configured via
    DataverseEnvironmentUrl in maester-config.json). This function reads the
    pre-resolved connection details from the module session.

    Results are cached in the module session for reuse by multiple test functions.

.EXAMPLE
    Get-MtAIAgentInfo

    Returns a list of AI agent objects with their configuration properties.

.LINK
    https://maester.dev/docs/commands/Get-MtAIAgentInfo
#>


function Get-MtAIAgentInfo {
    [CmdletBinding()]
    [OutputType([psobject[]])]
    param()

    if ($null -ne $__MtSession.AIAgentInfo) {
        if ($__MtSession.AIAgentInfo.Count -eq 0) {
            Write-Verbose "Previous Copilot Studio query failed or returned no results. Skipping."
            return $null
        }
        Write-Verbose "Returning cached AI agent info."
        return $__MtSession.AIAgentInfo
    }

    # Read pre-resolved Dataverse connection details from session (set by Connect-Maester)
    $apiBase = $__MtSession.DataverseApiBase
    $resourceUrl = $__MtSession.DataverseResourceUrl
    $environmentId = $__MtSession.DataverseEnvironmentId

    if ([string]::IsNullOrEmpty($apiBase) -or [string]::IsNullOrEmpty($resourceUrl)) {
        Write-Warning "Dataverse connection not established. Ensure you are connected via 'Connect-Maester -Service Dataverse'."
        $__MtSession.AIAgentInfo = @()
        return $null
    }

    Write-Verbose "Querying Copilot Studio agents from Dataverse: $apiBase"

    # Get access token via Az module
    try {
        $tokenResult = Get-AzAccessToken -ResourceUrl $resourceUrl -ErrorAction Stop
        if ($tokenResult.Token -is [System.Security.SecureString]) {
            $token = $tokenResult.Token | ConvertFrom-SecureString -AsPlainText
        } else {
            $token = $tokenResult.Token
        }
    } catch {
        Write-Warning "Failed to get Dataverse access token for Copilot Studio. Ensure you are connected via 'Connect-Maester -Service Dataverse'. Error: $_"
        $__MtSession.AIAgentInfo = @()
        return $null
    }

    $headers = @{
        Authorization      = "Bearer $token"
        Accept             = 'application/json'
        'OData-MaxVersion' = '4.0'
        'OData-Version'    = '4.0'
    }

    # Option set mappings (Dataverse stores these as integers)
    $accessControlPolicyMap = @{
        0 = 'Any'
        1 = 'Agent readers'
        2 = 'Group membership'
        3 = 'Any multitenant'
    }

    $authenticationModeMap = @{
        0 = 'Unspecified'
        1 = 'None'
        2 = 'Integrated'
        3 = 'Custom Entra ID'
        4 = 'Generic OAuth2'
    }

    $authenticationTriggerMap = @{
        0 = 'As Needed'
        1 = 'Always'
    }

    # Query all bots (exclude managed/built-in bots like 'Copilot in Power Apps')
    try {
        $selectFields = 'botid,name,accesscontrolpolicy,authenticationmode,authenticationtrigger,authorizedsecuritygroupids,statecode,statuscode,modifiedon,publishedon,configuration,schemaname,_ownerid_value,_createdby_value'
        $botsResponse = Invoke-RestMethod -Uri "$apiBase/bots?`$filter=ismanaged eq false&`$select=$selectFields" -Headers $headers -ErrorAction Stop
    } catch {
        Write-Warning "Failed to query Copilot Studio agents from Dataverse: $_"
        $__MtSession.AIAgentInfo = @()
        return $null
    }

    if ($null -eq $botsResponse.value -or $botsResponse.value.Count -eq 0) {
        Write-Verbose "No Copilot Studio agents found in the Dataverse environment."
        $__MtSession.AIAgentInfo = @()
        return $null
    }

    # Build a cache of systemuser UPNs for owner/creator resolution
    $userCache = @{}
    $userIds = @()
    foreach ($bot in $botsResponse.value) {
        if ($bot._ownerid_value -and -not $userCache.ContainsKey($bot._ownerid_value)) {
            $userIds += $bot._ownerid_value
            $userCache[$bot._ownerid_value] = $null
        }
        if ($bot._createdby_value -and -not $userCache.ContainsKey($bot._createdby_value)) {
            $userIds += $bot._createdby_value
            $userCache[$bot._createdby_value] = $null
        }
    }

    foreach ($userId in $userIds) {
        try {
            $user = Invoke-RestMethod -Uri "$apiBase/systemusers($userId)?`$select=domainname" -Headers $headers -ErrorAction Stop
            $userCache[$userId] = $user.domainname
        } catch {
            Write-Verbose "Could not resolve systemuser $userId : $_"
            $userCache[$userId] = $userId
        }
    }

    # Process each bot
    $agents = @()
    foreach ($bot in $botsResponse.value) {
        # Map option set values to strings
        $acpValue = if ($null -ne $bot.accesscontrolpolicy -and $accessControlPolicyMap.ContainsKey([int]$bot.accesscontrolpolicy)) {
            $accessControlPolicyMap[[int]$bot.accesscontrolpolicy]
        } else { "Unknown ($($bot.accesscontrolpolicy))" }

        $authModeValue = if ($null -ne $bot.authenticationmode -and $authenticationModeMap.ContainsKey([int]$bot.authenticationmode)) {
            $authenticationModeMap[[int]$bot.authenticationmode]
        } else { "Unknown ($($bot.authenticationmode))" }

        $authTriggerValue = if ($null -ne $bot.authenticationtrigger -and $authenticationTriggerMap.ContainsKey([int]$bot.authenticationtrigger)) {
            $authenticationTriggerMap[[int]$bot.authenticationtrigger]
        } else { "Unknown ($($bot.authenticationtrigger))" }

        # Determine agent status from statecode and publishedon
        $agentStatus = if ($bot.statecode -eq 1) { 'Inactive' }
            elseif ($null -ne $bot.publishedon) { 'Published' }
            else { 'Provisioned' }

        # Parse configuration for generative orchestration
        $generativeEnabled = $false
        if (-not [string]::IsNullOrEmpty($bot.configuration)) {
            try {
                $config = $bot.configuration | ConvertFrom-Json -ErrorAction Stop
                if ($config.settings.GenerativeActionsEnabled -eq $true) {
                    $generativeEnabled = $true
                }
            } catch {
                Write-Verbose "Could not parse configuration for bot $($bot.name): $_"
            }
        }

        # Get bot components (topics and tools)
        # componenttype 9 = Topic/Dialog, componenttype 15 = GptComponentMetadata
        $topicsData = @()
        $toolsData = @()
        try {
            $components = Invoke-RestMethod -Uri "$apiBase/botcomponents?`$filter=_parentbotid_value eq '$($bot.botid)' and componenttype eq 9&`$select=name,componenttype,data,schemaname" -Headers $headers -ErrorAction Stop
            foreach ($comp in $components.value) {
                if ([string]::IsNullOrEmpty($comp.data)) { continue }

                if ($comp.data -match 'kind:\s*TaskDialog') {
                    # Tool/action component (connector-based)
                    $toolsData += [PSCustomObject]@{
                        Name       = $comp.name
                        SchemaName = $comp.schemaname
                        Data       = $comp.data
                    }
                } else {
                    # Regular topic
                    $topicsData += [PSCustomObject]@{
                        Name       = $comp.name
                        SchemaName = $comp.schemaname
                        Data       = $comp.data
                    }
                }
            }
        } catch {
            Write-Verbose "Could not get bot components for $($bot.name): $_"
        }

        # Convert to JSON strings for pattern matching (tests use string matching)
        $topicsJson = if ($topicsData.Count -gt 0) {
            $topicsData | ConvertTo-Json -Depth 5 -Compress
        } else { $null }

        $toolsJson = if ($toolsData.Count -gt 0) {
            $toolsData | ConvertTo-Json -Depth 5 -Compress
        } else { $null }

        # Resolve owner and creator UPNs
        $ownerUpn = if ($bot._ownerid_value -and $userCache.ContainsKey($bot._ownerid_value)) {
            $userCache[$bot._ownerid_value]
        } else { $null }

        $creatorUpn = if ($bot._createdby_value -and $userCache.ContainsKey($bot._createdby_value)) {
            $userCache[$bot._createdby_value]
        } else { $null }

        $agents += [PSCustomObject]@{
            AIAgentId                        = $bot.botid
            AIAgentName                      = $bot.name
            AgentStatus                      = $agentStatus
            EnvironmentId                    = $environmentId
            AccessControlPolicy              = $acpValue
            UserAuthenticationType           = $authModeValue
            AuthenticationTrigger            = $authTriggerValue
            AuthorizedSecurityGroupIds       = $bot.authorizedsecuritygroupids
            AgentTopicsDetails               = $topicsJson
            AgentToolsDetails                = $toolsJson
            RawAgentInfo                     = $bot.configuration
            IsGenerativeOrchestrationEnabled = $generativeEnabled
            CreatorAccountUpn                = $creatorUpn
            OwnerAccountUpns                 = $ownerUpn
            LastPublishedTime                = $bot.publishedon
            LastModifiedTime                 = $bot.modifiedon
            SchemaName                       = $bot.schemaname
        }
    }

    $__MtSession.AIAgentInfo = $agents
    return $agents
}