workflows/default/systems/ui/modules/NotificationPoller.psm1

<#
.SYNOPSIS
Background poller that checks DotbotServer for external responses to needs-input tasks.

.DESCRIPTION
Periodically scans the needs-input directory for tasks with notification metadata,
polls DotbotServer for responses, and transitions answered tasks back to analysing
using the same logic as task-answer-question.

Uses first-write-wins: if a task has already been answered via the Web UI (moved out
of needs-input), the external response is silently ignored.
#>


$script:pollerPowerShell = $null
$script:pollerBotRoot = $null

function Initialize-NotificationPoller {
    <#
    .SYNOPSIS
    Starts the background notification polling timer.

    .PARAMETER BotRoot
    The .bot root directory path.
    #>

    param(
        [Parameter(Mandatory)]
        [string]$BotRoot
    )

    $script:pollerBotRoot = $BotRoot

    # Import the notification client module
    $notifModule = Join-Path $BotRoot "systems\mcp\modules\NotificationClient.psm1"
    if (-not (Test-Path $notifModule)) {
        return
    }
    Import-Module $notifModule -Force

    $settings = Get-NotificationSettings -BotRoot $BotRoot
    if (-not $settings.enabled) {
        return
    }

    $intervalSeconds = $settings.poll_interval_seconds
    if ($intervalSeconds -lt 5) { $intervalSeconds = 5 }

    # Use a dedicated runspace with a sleep loop — avoids the System.Threading.Timer
    # runspace issue where the TimerCallback scriptblock has no PowerShell runspace.
    $pollerRunspace = [runspacefactory]::CreateRunspace()
    $pollerRunspace.Open()

    $script:pollerPowerShell = [powershell]::Create()
    $script:pollerPowerShell.Runspace = $pollerRunspace

    $pollerModule = $PSCommandPath
    $script:pollerPowerShell.AddScript(@"
        Import-Module '$($pollerModule -replace "'","''")' -Force
        Import-Module '$($notifModule -replace "'","''")' -Force
        `$script:pollerBotRoot = '$($BotRoot -replace "'","''")'
        `$global:DotbotProjectRoot = '$((Split-Path $BotRoot -Parent) -replace "'","''")'

        while (`$true) {
            Start-Sleep -Seconds $intervalSeconds
            try {
                Invoke-NotificationPollTick -BotRoot `$script:pollerBotRoot
            } catch {
                # Swallow per-tick errors to keep polling
            }
        }
"@
)

    # BeginInvoke runs the loop asynchronously without blocking the main thread
    $null = $script:pollerPowerShell.BeginInvoke()
}

function Invoke-NotificationPollTick {
    <#
    .SYNOPSIS
    Single poll cycle: scans needs-input tasks for notification metadata,
    checks for external responses, and transitions answered tasks.
    #>

    param(
        [string]$BotRoot
    )

    $botRoot = if ($BotRoot) { $BotRoot } else { $script:pollerBotRoot }
    if (-not $botRoot) { return }

    $needsInputDir = Join-Path $botRoot "workspace\tasks\needs-input"
    if (-not (Test-Path $needsInputDir)) { return }

    # Ensure notification client is loaded
    $notifModule = Join-Path $botRoot "systems\mcp\modules\NotificationClient.psm1"
    if (-not (Test-Path $notifModule)) { return }
    Import-Module $notifModule -Force

    $settings = Get-NotificationSettings -BotRoot $botRoot
    if (-not $settings.enabled) { return }

    $taskFiles = Get-ChildItem -Path $needsInputDir -Filter "*.json" -File -ErrorAction SilentlyContinue
    if (-not $taskFiles) { return }

    foreach ($taskFile in $taskFiles) {
        try {
            $taskContent = Get-Content -Path $taskFile.FullName -Raw | ConvertFrom-Json
            $taskId = $taskContent.id

            # ── Single-question path (pending_question + notification) ──────
            $hasSingleNotif = $taskContent.PSObject.Properties['notification'] -and $taskContent.notification
            $hasSingleQ     = $taskContent.PSObject.Properties['pending_question'] -and $taskContent.pending_question

            # Determine notification type: question or split proposal
            $isQuestion  = [bool]$taskContent.pending_question
            $isSplit     = [bool]$taskContent.split_proposal
            $isBatchQs   = $taskContent.PSObject.Properties['pending_questions'] -and
                           $taskContent.pending_questions -and
                           @($taskContent.pending_questions).Count -gt 0

            # Skip tasks that have nothing actionable
            if (-not $isQuestion -and -not $isSplit -and -not $isBatchQs) {
                continue
            }

            $notification = $taskContent.notification
            $response = $null
            if ($notification) {
                $response = Get-TaskNotificationResponse -Notification $notification -Settings $settings
            }

            if ($response) {
                # Re-check that the task is still in needs-input (first-write-wins)
                if (-not (Test-Path $taskFile.FullName)) { continue }

                if ($isSplit) {
                    # Split proposal response: "approve" or "reject" key
                    $answerKey = if ($response.selectedKey) { $response.selectedKey } else { $null }
                    if ($answerKey) {
                        Invoke-SplitTransitionFromNotification -TaskFile $taskFile -TaskContent $taskContent `
                            -AnswerKey $answerKey -BotRoot $botRoot
                    } else {
                        # Unsupported response (e.g. free-text reply). The template
                        # disables free-text, but if a response without selectedKey
                        # still reaches us we must consume it — otherwise the same
                        # response is re-fetched on every poll tick indefinitely.
                        $taskContent.notification = $null
                        $taskContent | ConvertTo-Json -Depth 20 | Set-Content -Path $taskFile.FullName -Encoding UTF8
                    }
                } else {
                    # Question response: resolve answer and transition
                    $taskId    = $taskContent.id
                    $questionId = $taskContent.pending_question.id
                    $attachDir = Join-Path $botRoot "workspace\attachments\$taskId\$questionId"
                    $resolved  = Resolve-NotificationAnswer -Response $response -Settings $settings -AttachDir $attachDir

                    if ($resolved) {
                        Invoke-TaskTransitionFromNotification -TaskFile $taskFile -TaskContent $taskContent `
                            -Answer $resolved.answer -Attachments $resolved.attachments -BotRoot $botRoot
                    }
                }
                continue
            }

            # ── Batch path (pending_questions + notifications map) ──────────
            $hasBatchNotifs = $taskContent.PSObject.Properties['notifications'] -and $taskContent.notifications
            $hasBatchQs     = $taskContent.PSObject.Properties['pending_questions'] -and $taskContent.pending_questions

            if (-not $hasBatchNotifs -or -not $hasBatchQs) { continue }

            $pendingQs = @($taskContent.pending_questions)
            if ($pendingQs.Count -eq 0) { continue }

            foreach ($pq in $pendingQs) {
                $notifEntry = $null
                if ($taskContent.notifications.PSObject.Properties[$pq.id]) {
                    $notifEntry = $taskContent.notifications.($pq.id)
                }
                if (-not $notifEntry) { continue }

                $response = Get-TaskNotificationResponse -Notification $notifEntry -Settings $settings
                if (-not $response) { continue }

                $attachDir = Join-Path $botRoot "workspace\attachments\$taskId\$($pq.id)"
                $resolved  = Resolve-NotificationAnswer -Response $response -Settings $settings -AttachDir $attachDir
                if (-not $resolved) { continue }

                # Re-read task file before mutating (first-write-wins)
                if (-not (Test-Path $taskFile.FullName)) { break }
                $taskContent = Get-Content -Path $taskFile.FullName -Raw | ConvertFrom-Json

                Invoke-BatchQuestionTransitionFromNotification -TaskFile $taskFile -TaskContent $taskContent `
                    -Question $pq -Answer $resolved.answer -Attachments $resolved.attachments -BotRoot $botRoot

                # Re-read after mutation to pick up updated pending_questions for next iteration
                if (Test-Path $taskFile.FullName) {
                    $taskContent = Get-Content -Path $taskFile.FullName -Raw | ConvertFrom-Json
                } else {
                    break  # task moved out of needs-input — stop processing this file
                }
            }
        } catch {
            # Per-task errors are non-fatal; continue polling other tasks
        }
    }
}

function Invoke-TaskTransitionFromNotification {
    <#
    .SYNOPSIS
    Transitions a needs-input task back to analysing after receiving an external response.
    Mirrors the logic in task-answer-question/script.ps1.
    #>

    param(
        [Parameter(Mandatory)]
        [System.IO.FileInfo]$TaskFile,

        [Parameter(Mandatory)]
        [object]$TaskContent,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Answer,

        [Parameter(Mandatory)]
        [string]$BotRoot,

        [array]$Attachments = @()
    )

    $tasksBaseDir = Join-Path $BotRoot "workspace\tasks"
    $analysingDir = Join-Path $tasksBaseDir "analysing"
    $pendingQuestion = $TaskContent.pending_question

    # Resolve the answer (same logic as task-answer-question)
    $resolvedAnswer = $Answer
    $answerType = "custom"

    $validKeys = @("A", "B", "C", "D", "E")
    if ($Answer.ToUpper() -in $validKeys) {
        $answerKey = $Answer.ToUpper()
        $answerType = "option"
        $matchingOption = $pendingQuestion.options | Where-Object { $_.key -eq $answerKey } | Select-Object -First 1
        if ($matchingOption) {
            $resolvedAnswer = "$answerKey - $($matchingOption.label)"
        } else {
            $resolvedAnswer = $answerKey
        }
    }

    # Create resolved question entry
    $resolvedEntry = @{
        id          = $pendingQuestion.id
        question    = $pendingQuestion.question
        answer      = $resolvedAnswer
        answer_type = $answerType
        asked_at    = $pendingQuestion.asked_at
        answered_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
        answered_via = "notification"
    }

    if ($Attachments -and $Attachments.Count -gt 0) {
        $resolvedEntry['attachments'] = $Attachments
    }

    # Add to questions_resolved
    if (-not $TaskContent.PSObject.Properties['questions_resolved']) {
        $TaskContent | Add-Member -NotePropertyName 'questions_resolved' -NotePropertyValue @() -Force
    }
    $existingResolved = @($TaskContent.questions_resolved)
    $existingResolved += $resolvedEntry
    $TaskContent.questions_resolved = $existingResolved

    # Clear pending question
    $TaskContent.pending_question = $null
    $TaskContent.updated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")

    # Check if the answer indicates skip
    $isSkipAnswer = $resolvedAnswer -match '(?i)skip\s*task|skip\s*-|already\s*exist'

    if ($isSkipAnswer) {
        $TaskContent.status = 'skipped'
        if (-not $TaskContent.PSObject.Properties['skip_history']) {
            $TaskContent | Add-Member -NotePropertyName 'skip_history' -NotePropertyValue @() -Force
        }
        $existingSkips = @($TaskContent.skip_history)
        $existingSkips += @{
            skipped_at = $TaskContent.updated_at
            reason     = "Skipped via external notification answer: $resolvedAnswer"
        }
        $TaskContent.skip_history = $existingSkips

        $skippedDir = Join-Path $tasksBaseDir "skipped"
        if (-not (Test-Path $skippedDir)) {
            New-Item -ItemType Directory -Force -Path $skippedDir | Out-Null
        }
        $newFilePath = Join-Path $skippedDir $TaskFile.Name
    } else {
        $TaskContent.status = 'analysing'
        if (-not (Test-Path $analysingDir)) {
            New-Item -ItemType Directory -Force -Path $analysingDir | Out-Null
        }
        $newFilePath = Join-Path $analysingDir $TaskFile.Name
    }

    # Save updated task to new location and remove from needs-input
    $TaskContent | ConvertTo-Json -Depth 20 | Set-Content -Path $newFilePath -Encoding UTF8
    Remove-Item -Path $TaskFile.FullName -Force
}

function Invoke-SplitTransitionFromNotification {
    <#
    .SYNOPSIS
    Transitions a needs-input task based on a split-proposal response from Teams.
    Maps "approve"/"reject" answer keys to the corresponding task-approve-split logic.
    #>

    param(
        [Parameter(Mandatory)]
        [System.IO.FileInfo]$TaskFile,

        [Parameter(Mandatory)]
        [object]$TaskContent,

        [Parameter(Mandatory)]
        [string]$AnswerKey,

        [Parameter(Mandatory)]
        [string]$BotRoot
    )

    # Validate answer key — only "approve" and "reject" are expected
    $validKeys = @('approve', 'reject')
    if ($AnswerKey -notin $validKeys) {
        Write-BotLog -Level Warn -Message "Unexpected split proposal answer key '$AnswerKey' for task $($TaskContent.id) — ignoring"
        # Clear notification metadata so the same invalid response is not
        # re-fetched and re-logged on every subsequent poll tick.
        if (Test-Path $TaskFile.FullName) {
            $TaskContent.notification = $null
            $TaskContent | ConvertTo-Json -Depth 20 | Set-Content -Path $TaskFile.FullName -Encoding UTF8
        }
        return
    }

    $approved = $AnswerKey -eq 'approve'

    if (-not $approved) {
        # ── Reject path: mark rejected, move back to analysing ────────────
        $tasksBaseDir = Join-Path $BotRoot "workspace" "tasks"
        $analysingDir = Join-Path $tasksBaseDir "analysing"

        $TaskContent.status = 'analysing'
        $TaskContent.updated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")

        $TaskContent.split_proposal | Add-Member -NotePropertyName 'rejected_at' `
            -NotePropertyValue (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") -Force
        $TaskContent.split_proposal | Add-Member -NotePropertyName 'status' -NotePropertyValue 'rejected' -Force
        $TaskContent.split_proposal | Add-Member -NotePropertyName 'answered_via' -NotePropertyValue 'notification' -Force

        # Clear stale notification metadata so it doesn't carry over if the task
        # cycles back to needs-input with a new question or proposal
        $TaskContent.notification = $null

        if (-not (Test-Path $analysingDir)) {
            New-Item -ItemType Directory -Force -Path $analysingDir | Out-Null
        }

        $newFilePath = Join-Path $analysingDir $TaskFile.Name
        $TaskContent | ConvertTo-Json -Depth 20 | Set-Content -Path $newFilePath -Encoding UTF8
        Remove-Item -Path $TaskFile.FullName -Force
    } else {
        # ── Approve path: delegate to Invoke-TaskApproveSplit ─────────────
        # $global:DotbotProjectRoot is set in the runspace init block
        # (Initialize-NotificationPoller) so it's available here.
        $approveScript = Join-Path $BotRoot "systems" "mcp" "tools" "task-approve-split" "script.ps1"
        if (-not (Get-Command Invoke-TaskApproveSplit -ErrorAction SilentlyContinue)) {
            . $approveScript
        }

        try {
            $approveResult = Invoke-TaskApproveSplit -Arguments @{
                task_id  = $TaskContent.id
                approved = $true
            }

            # Record that the approval came via Teams notification (for audit trail)
            if ($approveResult.file_path -and (Test-Path $approveResult.file_path)) {
                $approvedTask = Get-Content -Path $approveResult.file_path -Raw | ConvertFrom-Json
                $approvedTask.split_proposal | Add-Member -NotePropertyName 'answered_via' -NotePropertyValue 'notification' -Force
                $approvedTask.notification = $null
                $approvedTask | ConvertTo-Json -Depth 20 | Set-Content -Path $approveResult.file_path -Encoding UTF8
            }
        } catch {
            # Clear notification metadata to prevent infinite retry loops on
            # persistent failures (e.g., task already moved, sub-task creation broken)
            Write-BotLog -Level Warn -Message "Split approval failed for task $($TaskContent.id): $($_.Exception.Message)" -Exception $_
            if (Test-Path $TaskFile.FullName) {
                $TaskContent.notification = $null
                $TaskContent.updated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
                $TaskContent | ConvertTo-Json -Depth 20 | Set-Content -Path $TaskFile.FullName -Encoding UTF8
            }
        }
    }
}

function Invoke-BatchQuestionTransitionFromNotification {
    <#
    .SYNOPSIS
    Handles a single answered question in a batch (pending_questions) flow.
    Moves the question to questions_resolved, removes its notification entry,
    and transitions the task to 'analysing' only when all questions are answered.
    #>

    param(
        [Parameter(Mandatory)]
        [System.IO.FileInfo]$TaskFile,

        [Parameter(Mandatory)]
        [object]$TaskContent,

        [Parameter(Mandatory)]
        [object]$Question,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Answer,

        [Parameter(Mandatory)]
        [string]$BotRoot,

        [array]$Attachments = @()
    )

    $tasksBaseDir = Join-Path $BotRoot "workspace\tasks"
    $now = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")

    # Resolve option key to label (same logic as Invoke-TaskTransitionFromNotification)
    $resolvedAnswer = $Answer
    $answerType     = "custom"
    $validKeys      = @("A", "B", "C", "D", "E")
    if ($Answer.ToUpper() -in $validKeys) {
        $answerKey = $Answer.ToUpper()
        $answerType = "option"
        $matchingOption = $Question.options | Where-Object { $_.key -eq $answerKey } | Select-Object -First 1
        $resolvedAnswer = if ($matchingOption) { "$answerKey - $($matchingOption.label)" } else { $answerKey }
    }

    # Build resolved entry
    $resolvedEntry = @{
        id           = $Question.id
        question     = $Question.question
        answer       = $resolvedAnswer
        answer_type  = $answerType
        asked_at     = $Question.asked_at
        answered_at  = $now
        answered_via = "notification"
    }
    if ($Attachments -and $Attachments.Count -gt 0) {
        $resolvedEntry['attachments'] = $Attachments
    }

    # Append to questions_resolved
    if (-not $TaskContent.PSObject.Properties['questions_resolved']) {
        $TaskContent | Add-Member -NotePropertyName 'questions_resolved' -NotePropertyValue @() -Force
    }
    $TaskContent.questions_resolved = @($TaskContent.questions_resolved) + $resolvedEntry

    # Remove from pending_questions
    $TaskContent.pending_questions = @($TaskContent.pending_questions | Where-Object { $_.id -ne $Question.id })

    # Remove notification entry for this question
    if ($TaskContent.PSObject.Properties['notifications'] -and $TaskContent.notifications.PSObject.Properties[$Question.id]) {
        $TaskContent.notifications.PSObject.Properties.Remove($Question.id)
    }

    $TaskContent.updated_at = $now

    $remainingCount = @($TaskContent.pending_questions).Count

    if ($remainingCount -gt 0) {
        # More questions pending — stay in needs-input, just update the file in place
        $TaskContent | ConvertTo-Json -Depth 20 | Set-Content -Path $TaskFile.FullName -Encoding UTF8
    } else {
        # All answered — transition to analysing (or skipped)
        $isSkipAnswer = $resolvedAnswer -match '(?i)skip\s*task|skip\s*-|already\s*exist'

        if ($isSkipAnswer) {
            $TaskContent.status = 'skipped'
            if (-not $TaskContent.PSObject.Properties['skip_history']) {
                $TaskContent | Add-Member -NotePropertyName 'skip_history' -NotePropertyValue @() -Force
            }
            $TaskContent.skip_history = @($TaskContent.skip_history) + @{
                skipped_at = $now
                reason     = "Skipped via external notification answer: $resolvedAnswer"
            }
            $destDir = Join-Path $tasksBaseDir "skipped"
        } else {
            $TaskContent.status = 'analysing'
            $destDir = Join-Path $tasksBaseDir "analysing"
        }

        if (-not (Test-Path $destDir)) {
            New-Item -ItemType Directory -Force -Path $destDir | Out-Null
        }
        $newFilePath = Join-Path $destDir $TaskFile.Name
        $TaskContent | ConvertTo-Json -Depth 20 | Set-Content -Path $newFilePath -Encoding UTF8
        Remove-Item -Path $TaskFile.FullName -Force
    }
}

Export-ModuleMember -Function @(
    'Initialize-NotificationPoller'
    'Invoke-NotificationPollTick'
    'Invoke-SplitTransitionFromNotification'
    'Invoke-BatchQuestionTransitionFromNotification'
    'Invoke-TaskTransitionFromNotification'
)