<# S5:IG 客服上线转人工(方案 docs\第三方chatbot对接\IG 客服上线转人工方案.html)。 服务号在机器人状态时新 IG 用户建出机器人工单 → SCRM 开工(切人工、在线 3)+ 客服上线 → 用户下一条:第三方照常回 echo,问句(带按钮)排在 echo 之后 → 点 Yes → 等待语 → 旧单结束、新人工工单推给 SCRM → 第三方没收到「Yes」和之后的消息 → 客服回复、用户收到 → 第三方往旧单迟到的回复得 409 → 结束新单 → SCRM 下班。 前提:.\loop.ps1 up 各角色都健康;chatbot 以 --auto-reply echo 运行; epro_ig_demo 的 DisconnectChatbotFlowSetting 已打开(db\ixserver\loop_patch.sql 第 3 节)。 通过退出 0,失败退出 1 并打印三方记录;两种情况都在 run\reports\ 生成场景报告(观察台在跑时)。 用法:.\scenarios\s5-ig-handoff.ps1 [-TimeoutSec 60] [-YesTitle Yes] [-KeepShift] [-NoReport] #> param([int]$TimeoutSec = 60, [string]$YesTitle = 'Yes', [switch]$KeepShift, [switch]$NoReport) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $LoopNoReport = [bool]$NoReport . (Join-Path $PSScriptRoot '_lib.ps1') $Cfg = $LoopCfg $ig = "http://127.0.0.1:$($Cfg.Ports.IgMock)" $sc = "http://127.0.0.1:$($Cfg.Ports.ScrmMock)" $cb = "http://127.0.0.1:$($Cfg.Ports.Chatbot)" $api = "http://localhost:$($Cfg.Ports.Ixserver)" function Post([string]$url, $body) { $bytes = [Text.Encoding]::UTF8.GetBytes(($body | ConvertTo-Json -Compress -Depth 6)) try { return Invoke-RestMethod $url -Method Post -ContentType 'application/json; charset=utf-8' -Body $bytes } catch { # 模拟方动作失败时回 4xx / 502,正文里带着原因 $resp = $_.Exception.Response if (-not $resp) { throw } $reader = New-Object IO.StreamReader($resp.GetResponseStream(), [Text.Encoding]::UTF8) return ($reader.ReadToEnd() | ConvertFrom-Json) } } # 失败也要把服务号切回机器人、客服下线,否则后面跑 S1 的新用户会直接进人工单 function Stop-Shift { if ($KeepShift) { return } Post "$sc/_api/agent-offline" @{} | Out-Null Post "$sc/_api/shift" @{ on = $false } | Out-Null } function Fail([string]$why) { Write-Host "S5 失败:$why" -ForegroundColor Red Write-Host '--- IG 模拟记录(这位用户)' (Invoke-RestMethod "$ig/_api/state").events | Where-Object { $_.igsid -eq $igsid } | Sort-Object id | ForEach-Object { '{0} {1} {2} {3}' -f $_.id, $_.kind, $_.type, $(if ($_.kind -eq 'outbound') { $_.summary } else { $_.text }) } | Out-Host Write-Host '--- SCRM 模拟记录(最近 10 条)' (Invoke-RestMethod "$sc/_api/state").events | Select-Object -First 10 | ForEach-Object { ($_ | ConvertTo-Json -Depth 8 -Compress) } | Out-Host Write-Host '--- chatbot 记录(最近 6 条)' (Invoke-RestMethod "$cb/_api/state").events | Select-Object -First 6 | ForEach-Object { '{0} ticket={1} {2}' -f $_.kind, $_.ticketId, $_.message } | Out-Host Add-LoopStep $why $false Stop-Shift Save-LoopReport -Passed $false -Reason $why exit 1 } function Step([string]$name, [string]$action, $body) { $r = Post "$sc/_api/$action" $body if (-not $r.ok) { Fail "$name($action)未成功:$($r.error) $((@($r.calls) | ForEach-Object { $_.api + ' → ' + ($_.response.body.msg) }) -join ';')" } Add-LoopStep $name $true ((@($r.calls) | ForEach-Object { $_.api }) -join ' + ') } function Wait-For([string]$what, [scriptblock]$probe) { $deadline = (Get-Date).AddSeconds($TimeoutSec) while ((Get-Date) -lt $deadline) { $hit = & $probe if ($hit) { return $hit } Start-Sleep -Milliseconds 300 } Fail "$TimeoutSec 秒内没等到:$what" } function Send-Ig($body) { $body.igsid = $igsid $r = Post "$ig/_api/send" $body if (-not $r.event -or $r.event.response.status -ne 200) { Fail "iXserver 收 webhook 返回 $($r.event.response.status) $($r.error)" } return $r.event } # 这位用户在某个入站事件之后收到的消息(IG 模拟的事件 id 全局递增) function Get-Outbound([int]$afterId) { @((Invoke-RestMethod "$ig/_api/state").events | Where-Object { $_.kind -eq 'outbound' -and $_.igsid -eq $igsid -and $_.id -gt $afterId } | Sort-Object id) } # 问句上能点的:按钮模板的 postback 按钮,或回退时的 quick reply function Get-Choices($out) { $m = $out.body.message if ($m.attachment -and $m.attachment.type -eq 'template') { return @($m.attachment.payload.buttons | Where-Object { $_.type -eq 'postback' } | ForEach-Object { @{ kind = 'postback'; form = '按钮模板'; title = $_.title; payload = $_.payload } }) } return @($m.quick_replies | Where-Object { $_ } | ForEach-Object { @{ kind = 'quick_reply'; form = 'quick reply'; title = $_.title; payload = $_.payload } }) } function Get-Check($ticketId) { $r = Invoke-RestMethod "$api/DisconnectChatbot/GetCheckResult?ticketId=$ticketId" if ($r -is [string]) { $r = $r | ConvertFrom-Json } return $r } function Get-ChatbotDeliveries($ticketId) { @((Invoke-RestMethod "$cb/_api/state").events | Where-Object { $_.kind -eq 'inbound' -and $_.raw -like "*`"ticket_id`":$ticketId,*" }) } function New-Tag { [guid]::NewGuid().ToString('N').Substring(0, 6) } Start-LoopRun 'S5' 'IG 客服上线转人工' $igsid = 'loop-s5-' + [guid]::NewGuid().ToString('N').Substring(0, 8) $ws = (Invoke-RestMethod "$sc/_api/state").state.ws if ($ws.status -ne 'logged-in') { Fail "SCRM 模拟的 WebSocket 没登录($($ws.status) $($ws.lastError))" } Invoke-RestMethod "$sc/_api/clear" -Method Post | Out-Null Invoke-RestMethod "$ig/_api/clear" -Method Post | Out-Null # ---- 1. 服务号在机器人状态时建单:AI 状态只在建单那一刻定(方案 §2.3) Step 'SCRM 下班(服务号在机器人状态)' 'shift' @{ on = $false } # 条款同意那一句就建出了机器人工单,之后的 text1 落在同一张单上 $err = Complete-LoopTnC $igsid $TimeoutSec if ($err) { Fail $err } $text1 = 'S5 机器人阶段 ' + (New-Tag) $e1 = Send-Ig @{ kind = 'text'; text = $text1 } Add-LoopStep '新 IG 用户发消息' $true "$igsid「$text1」HTTP 200" $reply1 = Wait-For "第三方回复「echo: $text1」" { @((Invoke-RestMethod "$cb/_api/state").events | Where-Object { $_.kind -eq 'reply' -and $_.message -eq "echo: $text1" }) | Select-Object -First 1 } $oldTicket = $reply1.ticketId Set-LoopTicket $oldTicket Wait-For 'IG 用户收到 echo' { @(Get-Outbound $e1.id | Where-Object { $_.summary -eq "echo: $text1" }) | Select-Object -First 1 } | Out-Null $check = Get-Check $oldTicket if (-not $check.IsChatbot) { Fail "工单 $oldTicket 不在机器人状态(上一次场景没下班?)" } $context = if ($check.ContextJson) { $check.ContextJson | ConvertFrom-Json } else { $null } if (-not $context -or -not $context.Setting.Enable) { Fail 'epro_ig_demo 的 DisconnectChatbotFlowSetting 没打开:见 db\ixserver\loop_patch.sql 第 3 节,没 reset 过的库要单独执行那一段' } if ($check.Eligible) { Fail '服务号还在机器人状态,工单就被判成可以转人工' } Add-LoopStep '机器人工单;流程已开、此时不合格' $true "ticket $oldTicket · $($check.StateName)" # ---- 2. 开工 + 客服上线:上线那一刻不推任何消息,等用户下一条才判(方案 §2.4) Step 'SCRM 开工(切人工、在线 3)' 'shift' @{ on = $true } Step '客服上线' 'agent-online' @{} $check = Get-Check $oldTicket if (-not $check.Eligible) { Fail ("开工后工单仍不合格:服务号已切人工={0} 客服在线={1} 额度有空={2} 额度工单={3}" -f (-not $check.EnableServiceAutoRespone), $check.IsCSOnline, $check.HasFreeQuota, $check.IsQuota) } Add-LoopStep '开工后工单合格' $true '服务号人工 + 客服在线 + 额度有空 + 工单仍在机器人手里' # ---- 3. 下一条:第三方照常回复,问句排在回复之后(方案 §9.1) $text2 = 'S5 开工后 ' + (New-Tag) $e2 = Send-Ig @{ kind = 'text'; text = $text2 } $ask = Wait-For '问句(带按钮)' { @(Get-Outbound $e2.id | Where-Object { @(Get-Choices $_).Count }) | Select-Object -First 1 } $echo2 = @(Get-Outbound $e2.id | Where-Object { $_.summary -eq "echo: $text2" }) | Select-Object -First 1 if (-not $echo2) { Fail '问句发出时第三方的 echo 还没到:问句应排在第三方回复之后' } if ($echo2.id -gt $ask.id) { Fail '问句抢在第三方 echo 前面发出' } if ($ask.response.status -ne 200) { Fail "问句 Graph 应答 $($ask.response.status)(IG 模拟档位 $($ask.response.mode)),用户实际没收到" } if (@($ask.issues).Count) { Fail "问句出站请求有问题:$($ask.issues -join ';')" } $choices = @(Get-Choices $ask) $gapMs = [int]([datetime]$ask.at - [datetime]$echo2.at).TotalMilliseconds Add-LoopStep '问句排在第三方 echo 之后' $true ("{0} · 晚 {1}ms · 按钮:{2}" -f $choices[0].form, $gapMs, (($choices | ForEach-Object { $_.title }) -join ' / ')) $yes = $choices | Where-Object { $_.title -eq $YesTitle } | Select-Object -First 1 if (-not $yes) { $yes = $choices | Where-Object { $_.title -like "$YesTitle*" } | Select-Object -First 1 } if (-not $yes) { Fail "问句里没有「$YesTitle」按钮(有:$(($choices | ForEach-Object { $_.title }) -join ' / '))" } # ---- 4. 点 Yes:等待语 → 旧单结束 → 新人工工单推给 SCRM $e3 = Send-Ig @{ kind = $yes.kind; title = $yes.title; text = $yes.title; payload = $yes.payload } $waitMsg = Wait-For '点 Yes 后的等待语' { Get-Outbound $e3.id | Select-Object -First 1 } Add-LoopStep "点「$($yes.title)」后收到等待语" $true $waitMsg.summary $newTicket = Wait-For 'SCRM 收到新人工工单推送' { $pushes = @((Invoke-RestMethod "$sc/_api/state").events | Where-Object { $_.kind -eq 'push' -and (($_.parsed | ConvertTo-Json -Depth 8 -Compress) -match [regex]::Escape($igsid)) }) @($pushes | ForEach-Object { $_.ticketIds } | Where-Object { $_ -and [string]$_ -ne [string]$oldTicket }) | Select-Object -First 1 } Set-LoopTicket $newTicket Add-LoopStep 'SCRM 收到新人工工单' $true "ticket $oldTicket → $newTicket" # ---- 5. 客服接手;用户在新单里说的话也不该投给第三方 Step '指派给客服' 'assign' @{ ticket_id = $newTicket } Step '打开工单(验证信息 + 已读)' 'open' @{ ticket_id = $newTicket } $agentText = 'S5 客服回复 ' + (New-Tag) Step '客服回复' 'reply' @{ ticket_id = $newTicket; text = $agentText } $agentOut = Wait-For "IG 用户收到「$agentText」" { @(Get-Outbound $e3.id | Where-Object { $_.summary -eq $agentText }) | Select-Object -First 1 } if ($agentOut.response.status -ne 200) { Fail "客服回复 Graph 应答 $($agentOut.response.status),用户实际没收到" } Add-LoopStep 'IG 用户收到客服回复' $true $agentText # 按 ASCII 标记找:中文在各方的 JSON 里可能被转义成 \uXXXX $tag4 = New-Tag $text4 = "S5 人工阶段 $tag4" Send-Ig @{ kind = 'text'; text = $text4 } | Out-Null Wait-For 'SCRM 收到用户在人工单里的消息' { @((Invoke-RestMethod "$sc/_api/state").events | Where-Object { $_.kind -eq 'push' -and (($_.parsed | ConvertTo-Json -Depth 8 -Compress) -match $tag4) }) | Select-Object -First 1 } | Out-Null Add-LoopStep 'SCRM 收到用户在人工单里的消息' $true $text4 $deliveries = @(Get-ChatbotDeliveries $oldTicket) if ($deliveries.Count -ne 2) { Fail "第三方收到旧单 $($deliveries.Count) 次投递,应只有转人工前的 2 条(「$($yes.title)」不该投给第三方)" } if (@(Get-ChatbotDeliveries $newTicket).Count) { Fail "人工工单 $newTicket 的消息被投给了第三方" } if (@((Invoke-RestMethod "$cb/_api/state").events | Where-Object { $_.kind -eq 'inbound' -and $_.raw -like "*$tag4*" }).Count) { Fail '用户在人工单里的话被投给了第三方' } Add-LoopStep '第三方没收到「Yes」与转人工后的消息' $true '旧单只有转人工前的 2 次投递' # ---- 6. 第三方往已结束的旧单迟到回复:409 ticket-closed,用户收不到 $lateText = 'S5 第三方迟到的回复 ' + (New-Tag) $late = Post "$cb/_api/reply" @{ ticket_id = [int]$oldTicket; message = $lateText } if (-not $late.result -or $late.result.status -ne 409 -or $late.result.raw -notmatch 'ticket-closed') { Fail "旧单迟到回复应得 409 ticket-closed,实际:$(($late | ConvertTo-Json -Depth 4 -Compress))" } Start-Sleep -Milliseconds 1500 if (@(Get-Outbound $e3.id | Where-Object { $_.summary -eq $lateText }).Count) { Fail '旧单迟到的回复被发给了用户' } Add-LoopStep '旧单迟到回复 409,用户收不到' $true 'ticket-closed' # ---- 7. 收尾 Step '结束人工工单' 'complete' @{ ticket_id = $newTicket } if (-not $KeepShift) { Step '客服下线' 'agent-offline' @{} Step 'SCRM 下班(切回机器人、在线 0)' 'shift' @{ on = $false } } Write-Host "S5 通过:$igsid 机器人工单 $oldTicket → 开工后问句($($choices[0].form),晚于 echo ${gapMs}ms)→ 点「$($yes.title)」→ 人工工单 $newTicket → 客服回复送达 → 旧单迟到回复 409" -ForegroundColor Green Save-LoopReport -Passed $true exit 0