iXserverLoopEnv/scenarios/s5-ig-handoff.ps1

211 lines
13 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<#
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