feat: S5 IG 客服上线转人工场景
- scenarios/s5-ig-handoff.ps1:机器人状态建单 → SCRM 开工 + 客服上线(GetCheckResult 判合格)→ 下一条 echo 在前、问句在后 → 点 Yes → 等待语、新人工工单推给 SCRM → 客服回复送达 → 第三方只收到转人工前的 2 条 → 旧单迟到回复 409 → 下班; 失败也会客服下线、切回机器人 - loop_patch.sql 第 3 节:打开 epro_ig_demo 的 DisconnectChatbotFlowSetting(服务号在机器人状态时不合格,不影响 S1) - README:S5 用法与前提 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
d3c6dd07a9
commit
e7f656cb99
|
|
@ -53,12 +53,16 @@
|
|||
|
||||
```powershell
|
||||
.\scenarios\s1-ig-text-echo.ps1 # IG 文本 → iXserver → 第三方 chatbot echo → IG 收到
|
||||
.\scenarios\s5-ig-handoff.ps1 # 机器人工单 → SCRM 开工 + 客服上线 → 下一条:echo 在前、问句在后 → 点 Yes → 新人工工单 → 客服回复 → 旧单迟到回复 409 → 下班
|
||||
.\scenarios\s8-human-chain.ps1 # SCRM 开工 → 新 IG 用户 → 人工工单推送 → 指派 / 回复 → IG 收到 → 结束 → 下班
|
||||
```
|
||||
|
||||
S5 依赖 `epro_ig_demo` 的 `DisconnectChatbotFlowSetting` 已打开(`db\ixserver\loop_patch.sql` 第 3 节;没 reset 过的库要单独执行那一段),
|
||||
问句按钮标题跟 ABP 模板走,模板改过时用 `-YesTitle` 指定。
|
||||
|
||||
跑的时候开着观察台 <http://127.0.0.1:5090>,每一跳和状态变化会实时画成泳道时序图;`?ticket=<工单号>` 只看一张。
|
||||
|
||||
S8 结束时会把服务号切回机器人、在线人数归零,所以 S1 / S8 先后跑互不影响;要停在人工状态看页面加 `-KeepShift`。
|
||||
S5 / S8 结束时(失败也一样)会把服务号切回机器人、在线人数归零,所以几个场景先后跑互不影响;要停在人工状态看页面加 `-KeepShift`。
|
||||
|
||||
从 Mac 看几个页面用 SSH 本地端口转发:
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,17 @@ UPDATE dbo.service_account_config
|
|||
'$.ChatMessageSetting.CurrentProviderKey', N'AbpServiceAccountChatMessageProvider')
|
||||
WHERE code = N'epro_ig_demo';
|
||||
|
||||
-- 客服上线转人工(IG 转人工方案 §9.1):流程开关打开。服务号开着 chatbot 时 CheckEligible 第 ④ 条不成立,
|
||||
-- 所以不影响 S1;SCRM 开工(切人工 + 在线)后,遗留在机器人手里的工单下一条消息就会问要不要转人工。
|
||||
-- 线上 Line 服务号的实际配置未导出,这里取方案的默认组合:问句排在第三方回复之后、No 继续机器人
|
||||
UPDATE dbo.service_account_config
|
||||
SET all_setting = JSON_MODIFY(all_setting, '$.DisconnectChatbotFlowSetting', JSON_QUERY(N'{
|
||||
"Enable": true, "NeedNormalRespone": true, "EnableReconfirm": false, "EnableShowGoodbye": true,
|
||||
"MatchCaseSensitive": false, "YesValues": ["yes", "contact agent"], "NoValues": ["no"],
|
||||
"ClickNoToEndTicket": false, "ClickNoToShowMainMenu": false, "NeedInclueResponeInLastTicketInfo": true,
|
||||
"NeedReactiveAfterIgnore": false, "NeedGreetingForNewTicket": false }'))
|
||||
WHERE code = N'epro_ig_demo';
|
||||
|
||||
------------------------------------------------------------------------------------------------
|
||||
-- 4. 第三方 client 登记:两张表都要有 setsail(方案 §2 链路 c)。ip_whitelist 留空 = 不限来源
|
||||
------------------------------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
<#
|
||||
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 = '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
|
||||
Loading…
Reference in New Issue