211 lines
13 KiB
PowerShell
211 lines
13 KiB
PowerShell
<#
|
||
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
|