workflows/default/systems/mcp/modules/NotificationClient.psm1

<#
.SYNOPSIS
Client module for DotbotServer external notifications (Teams, Email, Jira).

.DESCRIPTION
Provides functions to send task questions to DotbotServer and poll for responses.
All functions are no-op when notifications are disabled or the server is unreachable.
Used by task-mark-needs-input to dispatch notifications and by NotificationPoller
to collect external responses.
#>


function Get-NotificationSettings {
    <#
    .SYNOPSIS
    Reads the notifications section from merged dotbot settings.

    .PARAMETER BotRoot
    The .bot root directory. Defaults to $global:DotbotProjectRoot/.bot.

    .OUTPUTS
    PSCustomObject with enabled, server_url, api_key, channel, recipients, project_name,
    project_description, poll_interval_seconds. Returns disabled defaults if not configured.
    #>

    param(
        [string]$BotRoot
    )

    if (-not $BotRoot) {
        $BotRoot = Join-Path $global:DotbotProjectRoot ".bot"
    }

    $defaults = @{
        enabled                = $false
        server_url             = ""
        api_key                = ""
        channel                = "teams"
        recipients             = @()
        project_name           = ""
        project_description    = ""
        poll_interval_seconds  = 30
        sync_tasks             = $true
        sync_questions         = $true
        instance_id            = ""
    }

    # Read settings.default.json
    $defaultsFile = Join-Path $BotRoot "settings\settings.default.json"
    $overridesFile = Join-Path $BotRoot ".control\settings.json"

    $merged = @{}
    foreach ($key in $defaults.Keys) { $merged[$key] = $defaults[$key] }

    # Layer: checked-in defaults
    if (Test-Path $defaultsFile) {
        try {
            $settingsJson = Get-Content -Path $defaultsFile -Raw | ConvertFrom-Json
            if ($settingsJson.PSObject.Properties['instance_id'] -and $settingsJson.instance_id) {
                $merged.instance_id = "$($settingsJson.instance_id)"
            }
            # Read from 'mothership' key (with 'notifications' fallback for migration)
            $sectionKey = if ($settingsJson.PSObject.Properties['mothership']) { 'mothership' }
                          elseif ($settingsJson.PSObject.Properties['notifications']) { 'notifications' }
                          else { $null }
            if ($sectionKey) {
                $notif = $settingsJson.$sectionKey
                foreach ($prop in $notif.PSObject.Properties) {
                    if ($merged.ContainsKey($prop.Name)) {
                        $merged[$prop.Name] = $prop.Value
                    }
                }
            }
        } catch { Write-BotLog -Level Debug -Message "Settings operation failed" -Exception $_ }
    }

    # Layer: user overrides (gitignored)
    if (Test-Path $overridesFile) {
        try {
            $overrides = Get-Content -Path $overridesFile -Raw | ConvertFrom-Json
            if ($overrides.PSObject.Properties['instance_id'] -and $overrides.instance_id) {
                $merged.instance_id = "$($overrides.instance_id)"
            }
            # Read from 'mothership' key (with 'notifications' fallback for migration)
            $sectionKey = if ($overrides.PSObject.Properties['mothership']) { 'mothership' }
                          elseif ($overrides.PSObject.Properties['notifications']) { 'notifications' }
                          else { $null }
            if ($sectionKey) {
                $notif = $overrides.$sectionKey
                foreach ($prop in $notif.PSObject.Properties) {
                    if ($merged.ContainsKey($prop.Name)) {
                        $merged[$prop.Name] = $prop.Value
                    }
                }
            }
        } catch { Write-BotLog -Level Debug -Message "Non-critical operation failed" -Exception $_ }
    }

    return [PSCustomObject]$merged
}

function Test-NotificationServer {
    <#
    .SYNOPSIS
    Returns $true if the DotbotServer is reachable.

    .PARAMETER Settings
    Notification settings from Get-NotificationSettings. If not provided, reads from config.
    #>

    param(
        [object]$Settings
    )

    if (-not $Settings) {
        $Settings = Get-NotificationSettings
    }

    if (-not $Settings.server_url) { return $false }

    $baseUrl = $Settings.server_url.TrimEnd('/')
    $healthUrl = "$baseUrl/api/health"

    try {
        $null = Invoke-RestMethod -Uri $healthUrl -Method Get -TimeoutSec 5 -ErrorAction Stop
        return $true
    } catch {
        return $false
    }
}

function Send-ServerNotification {
    <#
    .SYNOPSIS
    Shared plumbing for sending notifications to DotbotServer via the two-step
    API (POST /api/templates + POST /api/instances).

    .DESCRIPTION
    Private helper — not exported. Handles settings validation, project ID
    resolution, deterministic GUID generation, template publishing, and
    instance creation. Callers supply the composite key (for idempotency)
    and a pre-built template body.

    .PARAMETER CompositeKey
    A string used to derive a deterministic UUIDv5-style question ID
    (e.g. "<task-id>-<question-id>" or "<task-id>-split").

    .PARAMETER Template
    Hashtable with the card-specific fields: title, context, options,
    responseSettings. This function adds questionId, version, and project.

    .PARAMETER Settings
    Optional notification settings. If not provided, reads from config.

    .OUTPUTS
    Hashtable: @{ success; question_id; instance_id; channel; project_id }
    Returns @{ success = $false; reason = "..." } on any failure.
    #>

    param(
        [Parameter(Mandatory)]
        [string]$CompositeKey,

        [Parameter(Mandatory)]
        [hashtable]$Template,

        [object]$Settings
    )

    # Shallow clone to avoid mutating the caller's hashtable (reference type).
    # Only top-level keys (questionId, version, project) are added below, so
    # shallow is sufficient — nested values (options, responseSettings) are not mutated.
    $Template = $Template.Clone()

    if (-not $Settings) {
        $Settings = Get-NotificationSettings
    }

    if (-not $Settings.enabled -or -not $Settings.server_url -or -not $Settings.api_key) {
        return @{ success = $false; reason = "Notifications not configured" }
    }

    $recipients = @($Settings.recipients)
    if ($recipients.Count -eq 0) {
        return @{ success = $false; reason = "No recipients configured" }
    }

    $baseUrl = $Settings.server_url.TrimEnd('/')
    $headers = @{ "X-Api-Key" = $Settings.api_key }

    # Prefer stable workspace GUID as project ID; fallback to legacy slug
    $projectName = if ($Settings.project_name) { $Settings.project_name } else { "dotbot" }
    $projectDesc = if ($Settings.project_description) { $Settings.project_description } else { "" }
    $projectId = $null
    if ($Settings.PSObject.Properties['instance_id'] -and $Settings.instance_id) {
        $parsedProjectGuid = [guid]::Empty
        if ([guid]::TryParse("$($Settings.instance_id)", [ref]$parsedProjectGuid)) {
            $projectId = $parsedProjectGuid.ToString()
        }
    }
    if (-not $projectId) {
        $projectId = ($projectName.ToLower() -replace '[^a-z0-9]+', '-').Trim('-')
    }

    # Deterministic UUIDv5-style GUID from composite key for idempotent retries
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($CompositeKey)
    $sha1  = [System.Security.Cryptography.SHA1]::Create()
    try {
        $hash = $sha1.ComputeHash($bytes)
    } finally {
        $sha1.Dispose()
    }
    $guidBytes = New-Object 'System.Byte[]' 16
    [Array]::Copy($hash, $guidBytes, 16)
    $guidBytes[6] = ($guidBytes[6] -band 0x0F) -bor 0x50   # version 5
    $guidBytes[8] = ($guidBytes[8] -band 0x3F) -bor 0x80   # RFC 4122 variant
    $questionId = ([System.Guid]::new([byte[]]$guidBytes)).ToString()

    # ── Step 1: Publish template ──────────────────────────────────────────
    $Template['questionId'] = $questionId
    $Template['version']    = 1
    $Template['project']    = @{
        projectId   = $projectId
        name        = $projectName
        description = $projectDesc
    }

    try {
        $templateJson = $Template | ConvertTo-Json -Depth 20
        $null = Invoke-RestMethod -Uri "$baseUrl/api/templates" -Method Post `
            -Body $templateJson -ContentType 'application/json' -Headers $headers -TimeoutSec 15
    } catch {
        return @{ success = $false; reason = "Template publish failed: $($_.Exception.Message)" }
    }

    # ── Step 2: Create instance ───────────────────────────────────────────
    $instanceId = [guid]::NewGuid().ToString()
    $channel = if ($Settings.channel) { $Settings.channel } else { "teams" }

    $recipientEmails = @($recipients | Where-Object { $_ -match '@' })
    $recipientIds = @($recipients | Where-Object { $_ -notmatch '@' })

    $instanceReq = @{
        instanceId      = $instanceId
        projectId       = $projectId
        questionId      = $questionId
        questionVersion = 1
        channel         = $channel
        recipients      = @{}
    }

    if ($recipientEmails.Count -gt 0) {
        $instanceReq.recipients.emails = $recipientEmails
    }
    if ($recipientIds.Count -gt 0) {
        if ($channel -eq "slack") {
            $instanceReq.recipients.slackUserIds = $recipientIds
        } else {
            $instanceReq.recipients.userObjectIds = $recipientIds
        }
    }

    try {
        $instanceJson = $instanceReq | ConvertTo-Json -Depth 20
        $null = Invoke-RestMethod -Uri "$baseUrl/api/instances" -Method Post `
            -Body $instanceJson -ContentType 'application/json' -Headers $headers -TimeoutSec 15
    } catch {
        return @{ success = $false; reason = "Instance creation failed: $($_.Exception.Message)" }
    }

    return @{
        success     = $true
        question_id = $questionId
        instance_id = $instanceId
        channel     = $channel
        project_id  = $projectId
    }
}

function Send-TaskNotification {
    <#
    .SYNOPSIS
    Sends a task's pending_question to DotbotServer as an Adaptive Card.

    .PARAMETER TaskContent
    The task PSCustomObject containing id, name, pending_question, etc.

    .PARAMETER PendingQuestion
    The pending_question object from the task. Contains id, question, context,
    options (key/label/rationale), recommendation.

    .PARAMETER Settings
    Optional notification settings. If not provided, reads from config.

    .OUTPUTS
    Hashtable. On success: @{ success = $true; question_id; instance_id; channel; project_id }.
    On failure: @{ success = $false; reason = "..." } (reason is supplied by Send-ServerNotification).
    #>

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

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

        [object]$Settings
    )

    $compositeKey = "$($TaskContent.id)-$($PendingQuestion.id)"

    $templateOptions = @(foreach ($opt in $PendingQuestion.options) {
        @{
            optionId      = [guid]::NewGuid().ToString()
            key           = "$($opt.key)"
            title         = "$($opt.label)"
            summary       = if ($opt.rationale) { "$($opt.rationale)" } else { $null }
            isRecommended = ("$($opt.key)" -eq $PendingQuestion.recommendation)
        }
    })

    $template = @{
        title            = $PendingQuestion.question
        context          = if ($PendingQuestion.context) { $PendingQuestion.context } else { $null }
        options          = $templateOptions
        responseSettings = @{ allowFreeText = $true }
    }

    return Send-ServerNotification -CompositeKey $compositeKey -Template $template -Settings $Settings
}

function Send-SplitProposalNotification {
    <#
    .SYNOPSIS
    Sends a task's split_proposal to DotbotServer as an Adaptive Card with
    Approve / Reject options and sub-task details.

    .PARAMETER TaskContent
    The task PSCustomObject containing id, name, split_proposal, etc.

    .PARAMETER SplitProposal
    The split_proposal object from the task. Contains reason, sub_tasks
    (each with name, description, effort), proposed_at.

    .PARAMETER Settings
    Optional notification settings. If not provided, reads from config.

    .OUTPUTS
    Hashtable. On success: @{ success = $true; question_id; instance_id; channel; project_id }.
    On failure: @{ success = $false; reason = "..." }.
    #>

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

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

        [object]$Settings
    )

    if (-not $SplitProposal.proposed_at) {
        return @{ success = $false; reason = "Split proposal missing proposed_at" }
    }

    # Use proposed_at in the composite key: it's stable for the lifetime of a
    # proposal (set once at creation, reused on notification retries), and new
    # proposals after rejection get a fresh timestamp — producing a new GUID.
    $compositeKey = "$($TaskContent.id)-split-$($SplitProposal.proposed_at)"

    if (-not $SplitProposal.sub_tasks -or @($SplitProposal.sub_tasks).Count -eq 0) {
        return @{ success = $false; reason = "Split proposal has no sub-tasks" }
    }

    # Build context body: reason + numbered sub-task list
    $subTaskLines = @()
    $index = 1
    foreach ($st in $SplitProposal.sub_tasks) {
        $effort = if ($st.effort) { " [$($st.effort)]" } else { "" }
        $desc   = if ($st.description) { " — $($st.description)" } else { "" }
        $subTaskLines += "$index. $($st.name)$effort$desc"
        $index++
    }
    $contextBody = "Reason: $($SplitProposal.reason)`n`nProposed sub-tasks:`n$($subTaskLines -join "`n")"

    $template = @{
        title            = "Split proposal for task: $($TaskContent.name)"
        context          = $contextBody
        options          = @(
            @{
                optionId      = [guid]::NewGuid().ToString()
                key           = "approve"
                title         = "Approve"
                summary       = "Accept the split and create the proposed sub-tasks"
                isRecommended = $true
            },
            @{
                optionId      = [guid]::NewGuid().ToString()
                key           = "reject"
                title         = "Reject"
                summary       = "Reject the split and return the task to analysis"
                isRecommended = $false
            }
        )
        # Split proposal is an explicit Approve/Reject binary choice — free-text
        # replies have no mapping in the poller and would leave the task stuck
        # in needs-input with the poller repeatedly re-fetching the same response.
        responseSettings = @{ allowFreeText = $false }
    }

    return Send-ServerNotification -CompositeKey $compositeKey -Template $template -Settings $Settings
}

function Get-TaskNotificationResponse {
    <#
    .SYNOPSIS
    Polls DotbotServer for a response to a previously sent notification.

    .PARAMETER Notification
    The notification metadata stored on the task (question_id, instance_id, etc.)

    .PARAMETER Settings
    Optional notification settings. If not provided, reads from config.

    .OUTPUTS
    Response object with selectedKey, freeText, etc. or $null if no response yet.
    #>

    param(
        [Parameter(Mandatory)]
        [object]$Notification,

        [object]$Settings
    )

    if (-not $Settings) {
        $Settings = Get-NotificationSettings
    }

    if (-not $Settings.enabled -or -not $Settings.server_url -or -not $Settings.api_key) {
        return $null
    }

    $baseUrl = $Settings.server_url.TrimEnd('/')
    $headers = @{ "X-Api-Key" = $Settings.api_key }

    $projectId = $Notification.project_id
    if (-not $projectId) {
        # Prefer settings.instance_id for backward-compatible polling fallback
        if ($Settings.PSObject.Properties['instance_id'] -and $Settings.instance_id) {
            $parsedProjectGuid = [guid]::Empty
            if ([guid]::TryParse("$($Settings.instance_id)", [ref]$parsedProjectGuid)) {
                $projectId = $parsedProjectGuid.ToString()
            }
        }
        if (-not $projectId) {
            $projectName = if ($Settings.project_name) { $Settings.project_name } else { "dotbot" }
            $projectId = ($projectName.ToLower() -replace '[^a-z0-9]+', '-').Trim('-')
        }
    }

    $questionId = $Notification.question_id
    $instanceId = $Notification.instance_id

    $responsesUrl = "$baseUrl/api/instances/$projectId/$questionId/$instanceId/responses"

    try {
        $responses = Invoke-RestMethod -Uri $responsesUrl -Method Get -Headers $headers -TimeoutSec 10 -ErrorAction Stop
        if ($responses -and @($responses).Count -gt 0) {
            return @($responses)[0]
        }
    } catch {
        # 404 means no responses yet; other errors are transient
    }

    return $null
}

function Resolve-NotificationAnswer {
    <#
    .SYNOPSIS
    Extracts the answer text from a Teams response and downloads any attached files.

    .PARAMETER Response
    The response object returned by Get-TaskNotificationResponse.

    .PARAMETER Settings
    Notification settings (needs server_url, api_key).

    .PARAMETER AttachDir
    Local directory to save attachment files into (created if needed).

    .OUTPUTS
    Hashtable with keys:
      answer - resolved answer string (with paths appended if attachments present)
      attachments - array of @{ name, size, path } metadata (empty array if none)
    Returns $null if no valid answer found in the response.
    #>

    param(
        [Parameter(Mandatory)] $Response,
        [Parameter(Mandatory)] $Settings,
        [Parameter(Mandatory)] [string]$AttachDir
    )

    $answer = if ($Response.selectedKey) { $Response.selectedKey }
              elseif ($Response.freeText)  { $Response.freeText }
              else                         { $null }

    $hasAttachments = $Response.attachments -and @($Response.attachments).Count -gt 0
    if (-not $answer -and -not $hasAttachments) { return $null }
    if (-not $answer) { $answer = '' }  # attachments-only — paths will be appended below

    $attachmentMeta = @()

    if ($Response.attachments -and @($Response.attachments).Count -gt 0) {
        if (-not (Test-Path $AttachDir)) {
            New-Item -ItemType Directory -Force -Path $AttachDir | Out-Null
        }

        foreach ($att in @($Response.attachments)) {
            try {
                # URL-encode the blob path to handle spaces and special chars in filenames
                $encodedPath = [System.Uri]::EscapeUriString("$($Settings.server_url.TrimEnd('/'))/api/attachments/$($att.blobPath)")
                $headers = @{ 'X-Api-Key' = $Settings.api_key }
                $localPath = Join-Path $AttachDir $att.name
                Invoke-RestMethod -Uri $encodedPath -Method Get -Headers $headers `
                    -OutFile $localPath -TimeoutSec 30 -ErrorAction Stop

                # Build a relative path using the last two directory segments for portability
                $relPath = ($localPath -replace '\\', '/') -replace '^.*?/workspace/', '.bot/workspace/'
                $attachmentMeta += @{ name = $att.name; size = $att.sizeBytes; path = $relPath }
            } catch {
                Write-BotLog -Level Warn -Message "Attachment download failed: $($att.name)" -Exception $_
            }
        }

        if ($attachmentMeta.Count -gt 0) {
            $pathList = ($attachmentMeta | ForEach-Object { $_.path }) -join ', '
            $answer = if ($answer) { "$answer`nAttached: $pathList" } else { "Attached: $pathList" }
        } elseif (-not $answer) {
            # Attachments were present but all downloads failed — still acknowledge them
            $answer = "(attachment provided but could not be downloaded)"
        }
    }

    return @{
        answer      = $answer
        attachments = $attachmentMeta
    }
}

Export-ModuleMember -Function @(
    'Get-NotificationSettings'
    'Test-NotificationServer'
    'Send-TaskNotification'
    'Send-SplitProposalNotification'
    'Get-TaskNotificationResponse'
    'Resolve-NotificationAnswer'
)