<# 场景脚本公用:计时、记下每一步断言,结束时(通过或失败)请闭环观察台生成场景报告。 报告是观察台截取这段时间窗里的每一跳、状态变化、iXserver 日志,连同断言与部署版本存成的单文件 HTML, 放在 run\reports\(不进 git),列表在 http://127.0.0.1:/reports/。 用法(在场景脚本里点源): . (Join-Path $PSScriptRoot '_lib.ps1') Start-LoopRun 'S1' 'IG 文本 → 第三方 echo → IG' Add-LoopStep 'iXserver 收 webhook' $true 'HTTP 200' Set-LoopTicket 1973133303 Save-LoopReport -Passed $true # 或 Save-LoopReport -Passed $false -Reason '…' 脚本带 -NoReport 时设 $LoopNoReport = $true 就只断言不出报告(省掉等日志落盘的几秒)。 #> $LoopCfg = Import-PowerShellDataFile (Join-Path $PSScriptRoot '..\config\loop.psd1') $script:LoopRun = $null function Start-LoopRun([string]$Scenario, [string]$Title = '') { $script:LoopRun = [ordered]@{ scenario = $Scenario title = $Title startedAt = (Get-Date).ToUniversalTime().ToString('o') steps = New-Object System.Collections.ArrayList ticketId = '' } } function Add-LoopStep([string]$Name, [bool]$Ok, [string]$Detail = '') { if (-not $script:LoopRun) { return } [void]$script:LoopRun.steps.Add([ordered]@{ name = $Name; ok = $Ok; detail = $Detail }) } function Set-LoopTicket($TicketId) { if ($script:LoopRun -and $TicketId) { $script:LoopRun.ticketId = [string]$TicketId } } function Save-LoopReport([bool]$Passed, [string]$Reason = '') { $run = $script:LoopRun if (-not $run -or $LoopNoReport) { return } $run.endedAt = (Get-Date).ToUniversalTime().ToString('o') $run.passed = $Passed $run.reason = $Reason $console = "http://127.0.0.1:$($LoopCfg.Ports.Console)" try { $body = [Text.Encoding]::UTF8.GetBytes(($run | ConvertTo-Json -Depth 6 -Compress)) # 观察台要等 iXserver 日志落盘、库轮询跟上才截取,这一步会停几秒 $r = Invoke-RestMethod "$console/_api/report" -Method Post -ContentType 'application/json; charset=utf-8' -Body $body -TimeoutSec 60 Write-Host "报告:$($r.file)($($r.hops) 跳 / $($r.logs) 条日志;观察台里打开 $console$($r.url))" } catch { # 报告是附带的:观察台没在跑不影响场景本身的结果 Write-Host "没有生成报告(观察台没在跑?.\loop.ps1 up -Only console):$($_.Exception.Message)" -ForegroundColor Yellow } } # ============================================================================ 条款同意 # 以这位用户身份发一条入站;iXserver 没收下返回 $null function Send-LoopIg([string]$Igsid, [hashtable]$Body) { $Body.igsid = $Igsid $bytes = [Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Compress)) try { $r = Invoke-RestMethod "http://127.0.0.1:$($LoopCfg.Ports.IgMock)/_api/send" -Method Post -ContentType 'application/json; charset=utf-8' -Body $bytes } catch { return $null } # IG 模拟投递失败回 502 if ($r.event -and $r.event.response.status -eq 200) { return $r.event } return $null } # 这位用户在某个入站事件之后收到的消息(IG 模拟的事件 id 全局递增) function Get-LoopOutbound([string]$Igsid, [int]$AfterId) { @((Invoke-RestMethod "http://127.0.0.1:$($LoopCfg.Ports.IgMock)/_api/state").events | Where-Object { $_.kind -eq 'outbound' -and $_.igsid -eq $Igsid -and $_.id -gt $AfterId } | Sort-Object id) } function Wait-LoopOutbound([string]$Igsid, [int]$AfterId, [scriptblock]$Match, [int]$TimeoutSec) { $deadline = (Get-Date).AddSeconds($TimeoutSec) while ((Get-Date) -lt $deadline) { $hit = @(Get-LoopOutbound $Igsid $AfterId | Where-Object -FilterScript $Match) | Select-Object -First 1 if ($hit) { return $hit } Start-Sleep -Milliseconds 300 } return $null } # 一条出站消息上用户能点的:按钮模板 / 卡片的 postback 按钮,或 quick reply function Get-LoopChoices($Outbound) { $m = $Outbound.body.message if ($m.attachment -and $m.attachment.type -eq 'template') { $p = $m.attachment.payload $buttons = if ($p.buttons) { @($p.buttons) } else { @($p.elements | ForEach-Object { $_.buttons }) } return @($buttons | Where-Object { $_ -and $_.type -eq 'postback' } | ForEach-Object { @{ kind = 'postback'; title = $_.title; payload = $_.payload } }) } return @($m.quick_replies | Where-Object { $_ } | ForEach-Object { @{ kind = 'quick_reply'; title = $_.title; payload = $_.payload } }) } <# epro_ig_demo 常开条款流程(db\ixserver\loop_patch.sql 第 3 节):新 IG 用户的第一句话被条款拦下, 不进第三方也不进人工对话。场景先走完「发一句 → 收条款 → 同意 → 选语言 → 收问候」,再测自己的链路。 条款里有 Agree 按钮就照按钮的形态点(闭环 ABP 已补上,db\abp\loop_patch.sql 第 3 节); 没有时(库是 staging 原样、没打那一节)退回发裸 payload 的 postback(InstagramChatChannel.TnCFlow.IsTnCPayload 放行)。 每步记 Add-LoopStep。成功返回 $null,失败返回原因——收尾(下班、打印记录)各场景不同,交给调用方的 Fail: $err = Complete-LoopTnC $igsid $TimeoutSec; if ($err) { Fail $err } #> # 默认选 English:没有条款流程时端用户语言就是英文,S5 的问句按钮(-YesTitle Yes)跟着模板语言走 function Complete-LoopTnC([string]$Igsid, [int]$TimeoutSec = 60, [string]$Language = 'en-us') { $text = 'loop-tnc ' + [guid]::NewGuid().ToString('N').Substring(0, 6) $e = Send-LoopIg $Igsid @{ kind = 'text'; text = $text } if (-not $e) { return "条款前的第一句「$text」没送进 iXserver" } # 条款第一条带 4 个外链,标题里认得出来 $tnc = Wait-LoopOutbound $Igsid $e.id { $_.summary -like '*Terms and Conditions*' } $TimeoutSec if (-not $tnc) { return "$TimeoutSec 秒内没收到条款(T_and_C_Setting 没开?见 db\ixserver\loop_patch.sql 第 3 节;或 ABP 模板的 agreementMessages 为空)" } Add-LoopStep '新用户第一句话被条款拦下' $true "$Igsid「$text」→ $($tnc.summary)" Start-Sleep -Milliseconds 1500 # 条款组可能不止一条,等它发完再找 Agree 按钮 $agree = @(Get-LoopOutbound $Igsid $e.id | ForEach-Object { Get-LoopChoices $_ } | Where-Object { $_.payload -eq 'Agree' }) | Select-Object -First 1 $how = if ($agree) { "点模板里的「$($agree.title)」($($agree.kind))" } else { '条款里没有 Agree 按钮(ABP 没打 loop_patch 第 3 节?),发 postback Agree' } if (-not $agree) { $agree = @{ kind = 'postback'; title = 'Agree'; payload = 'Agree' } } $e = Send-LoopIg $Igsid @{ kind = $agree.kind; title = $agree.title; payload = $agree.payload; text = $agree.title } if (-not $e) { return '同意没送进 iXserver' } $switchPayload = "switch $Language" $menu = Wait-LoopOutbound $Igsid $e.id { @(Get-LoopChoices $_ | Where-Object { $_.payload -eq $switchPayload }).Count } $TimeoutSec if (-not $menu) { return "$TimeoutSec 秒内同意之后没收到带「$switchPayload」的选语言按钮" } Add-LoopStep '同意条款,收到选语言' $true $how $choice = @(Get-LoopChoices $menu | Where-Object { $_.payload -eq $switchPayload })[0] $e = Send-LoopIg $Igsid @{ kind = $choice.kind; title = $choice.title; payload = $choice.payload; text = $choice.title } if (-not $e) { return '选语言没送进 iXserver' } $greeting = Wait-LoopOutbound $Igsid $e.id { $true } $TimeoutSec if (-not $greeting) { return "$TimeoutSec 秒内选完语言没收到问候(条款模板第四组)" } Add-LoopStep "选「$($choice.title)」,收到问候" $true $greeting.summary return $null }