From c35a4a1051d4294e855d58e6acd82e4d6ef35269 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 6 Sep 2026 01:24:50 +0800 Subject: [PATCH] fix: expand local session results and reject unsafe search targets --- .../M2-M3-Local-Search-2026-09-05.md | 34 +++++++ .../collapsed-before.json | 29 ++++++ .../Local-Search-20260905/doctor.json | 68 ++++++++++++++ .../Local-Search-20260905/named-summary.json | 23 +++++ .../Local-Search-20260905/search-summary.json | 22 +++++ .../evidence/Local-Search-20260905/smoke.json | 10 ++ scripts/windows/Test-LocalSessionSearch.ps1 | 48 ++++++++++ scripts/windows/Test-NamedListeners.ps1 | 39 ++++++++ src/WxAgent.Core/UiNodeSnapshot.cs | 8 +- src/WxAgent.Core/WechatLocators.cs | 1 + src/WxAgent.Core/WechatSearchPage.cs | 74 +++++++++++++++ src/WxAgent.Host/Program.cs | 12 +-- .../WechatChatClient.Management.cs | 8 +- .../WechatChatClient.Search.cs | 91 +++++++++++++++++++ src/WxAgent.Windows/WechatChatClient.cs | 56 +++++------- .../WechatSearchRulesTests.cs | 80 ++++++++++++++++ 16 files changed, 557 insertions(+), 46 deletions(-) create mode 100644 docs/validation/M2-M3-Local-Search-2026-09-05.md create mode 100644 docs/validation/evidence/Local-Search-20260905/collapsed-before.json create mode 100644 docs/validation/evidence/Local-Search-20260905/doctor.json create mode 100644 docs/validation/evidence/Local-Search-20260905/named-summary.json create mode 100644 docs/validation/evidence/Local-Search-20260905/search-summary.json create mode 100644 docs/validation/evidence/Local-Search-20260905/smoke.json create mode 100644 scripts/windows/Test-LocalSessionSearch.ps1 create mode 100644 scripts/windows/Test-NamedListeners.ps1 create mode 100644 src/WxAgent.Core/WechatSearchPage.cs create mode 100644 src/WxAgent.Windows/WechatChatClient.Search.cs create mode 100644 tests/WxAgent.Core.Tests/WechatSearchRulesTests.cs diff --git a/docs/validation/M2-M3-Local-Search-2026-09-05.md b/docs/validation/M2-M3-Local-Search-2026-09-05.md new file mode 100644 index 0000000..47333ab --- /dev/null +++ b/docs/validation/M2-M3-Local-Search-2026-09-05.md @@ -0,0 +1,34 @@ +# 本地会话搜索与指定会话监听回归(2026-09-05) + +## 验收环境 + +- Windows 10.0.19044,微信 4.1.13.63,交互式 Session 1。 +- 验证包:`releases/search-final-2/WxAgent.Host.exe`。 +- SHA-256:`C70EFB6DF2F3D222C773125A1305698E0443FA58DB1F56EB774850B94D4D343F`。 +- 本次仅完成搜索修复和命名独立窗口基线回归,不代表 M2/M3/M6 全阶段验收完成。 + +## 搜索行为与结果 + +- 限定 `search_list` 内的本地分类;联系人/群聊的“查看全部(N)”先展开,结果不完整则拒绝选择。 +- 精确名称唯一匹配后,重新定位并确认聊天标题。网络搜索、聊天记录、未知分类不作为会话目标。 +- 微信将已使用的会话提升至“最常使用”;此分类只接受已验证的名称型本地 ID 或文件传输助手的固定 ID,不接受任意功能/在线/新类型条目。 +- `session search --query 交流`:原折叠视图仅返回 3 条,修复后返回全部 10 条本地群聊。 +- 指定的“消息测试专用群组”不在这 10 条内。`session open --query 交流 --name 消息测试专用群组` 明确返回 `ControlNotFound`,没有猜选或点击网络搜索。 +- 改用完整名称的本地查询:`session open --query 消息测试专用群组 --name 消息测试专用群组` 成功,随后 `session current` 再次确认目标标题。 +- `doctor`、`inspect-ui`、`smoke` 全部通过。smoke 仅向文件传输助手发送测试标记,目标群发送数为 0。 + +## 监听回归与边界 + +- 文件传输助手、指定测试群同时使用 `chat monitor --independent`,每个运行 8 秒。 +- 两进程退出码均为 0;各自 checkpoint 的会话匹配,独立窗口 UI 检查通过,指纹数分别为 4、7。 +- 本项只验证并行绑定、基线采集和隔离 checkpoint,不证明真实新消息投递、群消息归属、长时间稳定性或完整重连场景。 +- PowerShell 测试保留进程句柄后读取退出码;缺少 checkpoint 时指纹数为 0,不能把 `$null` 数组算作成功证据。 + +## 可重复检查 + +- `scripts/windows/Test-LocalSessionSearch.ps1 -Executable -OutputDirectory <新目录>`。 +- `scripts/windows/Test-NamedListeners.ps1 -Executable -OutputDirectory <新目录>`。 +- Core:72/72 测试通过;全解决方案 Release 构建和 Windows self-contained 发布均为 0 警告、0 错误。 +- 脱敏验收记录:`evidence/Local-Search-20260905/`。失败的早期候选包保留在本地 `artifacts/`,未冒充最终通过证据。 + +后续仍需完成已有计划中的 M2 连续发送、M3 新消息/恢复验收及其他阶段的剩余项目;M6 8/24/72 小时正式验收尚未完成。 diff --git a/docs/validation/evidence/Local-Search-20260905/collapsed-before.json b/docs/validation/evidence/Local-Search-20260905/collapsed-before.json new file mode 100644 index 0000000..aa9a8bb --- /dev/null +++ b/docs/validation/evidence/Local-Search-20260905/collapsed-before.json @@ -0,0 +1,29 @@ +{ + "Count": 3, + "Rows": [ + { + "Family": "unknown", + "IdIsIndex": false, + "Category": "other", + "CategoryLength": 0, + "NameLength": 13, + "Exact": false + }, + { + "Family": "unknown", + "IdIsIndex": false, + "Category": "other", + "CategoryLength": 0, + "NameLength": 11, + "Exact": false + }, + { + "Family": "unknown", + "IdIsIndex": false, + "Category": "other", + "CategoryLength": 0, + "NameLength": 16, + "Exact": false + } + ] +} diff --git a/docs/validation/evidence/Local-Search-20260905/doctor.json b/docs/validation/evidence/Local-Search-20260905/doctor.json new file mode 100644 index 0000000..4972bed --- /dev/null +++ b/docs/validation/evidence/Local-Search-20260905/doctor.json @@ -0,0 +1,68 @@ +{ + "OperatingSystem": "Microsoft Windows NT 10.0.19044.0", + "UserInteractive": true, + "CurrentSessionId": 1, + "Processes": [ + { + "Id": 8912, + "WorkingSetBytes": 140099584, + "SessionId": 1, + "Version": "4.1.13.63", + "CanReadMemory": true, + "AccessError": null, + "HandleCount": 2523, + "ThreadCount": 126 + }, + { + "Id": 11064, + "WorkingSetBytes": 9207808, + "SessionId": 1, + "Version": "4.1.13.63", + "CanReadMemory": true, + "AccessError": null, + "HandleCount": 301, + "ThreadCount": 10 + }, + { + "Id": 17780, + "WorkingSetBytes": 8376320, + "SessionId": 1, + "Version": "4.1.13.63", + "CanReadMemory": true, + "AccessError": null, + "HandleCount": 376, + "ThreadCount": 15 + }, + { + "Id": 2436, + "WorkingSetBytes": 1191936, + "SessionId": 1, + "Version": "4.1.13.63", + "CanReadMemory": true, + "AccessError": null, + "HandleCount": 219, + "ThreadCount": 19 + }, + { + "Id": 14720, + "WorkingSetBytes": 987136, + "SessionId": 1, + "Version": "4.1.13.63", + "CanReadMemory": true, + "AccessError": null, + "HandleCount": 216, + "ThreadCount": 13 + } + ], + "WindowFound": true, + "RequiredControls": { + "MainView": true, + "session_list": true, + "chat_message_page": true, + "chat_message_list": true, + "chat_input_field": true, + "tool_bar_accessible": true + }, + "DataRootCount": 1, + "Errors": [] +} diff --git a/docs/validation/evidence/Local-Search-20260905/named-summary.json b/docs/validation/evidence/Local-Search-20260905/named-summary.json new file mode 100644 index 0000000..5457d0a --- /dev/null +++ b/docs/validation/evidence/Local-Search-20260905/named-summary.json @@ -0,0 +1,23 @@ +{ + "MessagesSent": 0, + "Scope": "Parallel named-window baseline only; not incoming-message delivery or endurance.", + "Checks": [ + { + "SessionMatches": true, + "ExitCode": 0, + "FingerprintCount": 4, + "CheckpointSaved": true, + "InspectExit": 0, + "Index": 0 + }, + { + "SessionMatches": true, + "ExitCode": 0, + "FingerprintCount": 7, + "CheckpointSaved": true, + "InspectExit": 0, + "Index": 1 + } + ], + "Passed": true +} diff --git a/docs/validation/evidence/Local-Search-20260905/search-summary.json b/docs/validation/evidence/Local-Search-20260905/search-summary.json new file mode 100644 index 0000000..3fb66aa --- /dev/null +++ b/docs/validation/evidence/Local-Search-20260905/search-summary.json @@ -0,0 +1,22 @@ +{ + "CurrentExit": 0, + "GroupMessagesSent": 0, + "BaselineInspectExit": 0, + "Success": true, + "ExpectedCount": 10, + "DoctorExit": 0, + "NegativeExit": 1, + "OpenExit": 0, + "ExpandedCount": 10, + "OpenedTargetMatches": true, + "OpenError": null, + "TargetInKeywordResults": 0, + "SmokeExit": 0, + "MissingExactTargetRejected": true, + "LocalCategoriesOnly": true, + "SearchExit": 0, + "OpenMessage": null, + "BinarySha256": "C70EFB6DF2F3D222C773125A1305698E0443FA58DB1F56EB774850B94D4D343F", + "CurrentTargetMatches": true, + "InspectExit": 0 +} diff --git a/docs/validation/evidence/Local-Search-20260905/smoke.json b/docs/validation/evidence/Local-Search-20260905/smoke.json new file mode 100644 index 0000000..f08d1b1 --- /dev/null +++ b/docs/validation/evidence/Local-Search-20260905/smoke.json @@ -0,0 +1,10 @@ +{ + "scope": "M1", + "doctorErrors": [], + "output": "C:\\Users\\Rogee\\wx-agent\\search-final-2-validation\\smoke-ui.json", + "nodes": 287, + "sanitized": true, + "sentTo": "file-transfer-assistant", + "Fingerprint": "8b823b27c22472a27bf884be2ca7a5b9083f04cfdc5fbb2e895b165b5897a0fc", + "confirmed": true +} diff --git a/scripts/windows/Test-LocalSessionSearch.ps1 b/scripts/windows/Test-LocalSessionSearch.ps1 new file mode 100644 index 0000000..29fd5ce --- /dev/null +++ b/scripts/windows/Test-LocalSessionSearch.ps1 @@ -0,0 +1,48 @@ +param( + [Parameter(Mandatory=$true)][string]$Executable, + [Parameter(Mandatory=$true)][string]$OutputDirectory, + [string]$Query='交流', + [string]$Target='消息测试专用群组', + [int]$ExpectedCount=10 +) +$ErrorActionPreference='Stop' +$exe=(Resolve-Path $Executable).Path +if(Test-Path $OutputDirectory){throw 'Use a fresh evidence directory.'} +$directory=(New-Item -ItemType Directory $OutputDirectory).FullName +& $exe doctor | Out-File "$directory\doctor.json" -Encoding UTF8 +$doctorCode=$LASTEXITCODE +& $exe inspect-ui --output "$directory\baseline-ui.json" | Out-Null +$baselineCode=$LASTEXITCODE +& $exe smoke --output "$directory\smoke-ui.json" | Out-File "$directory\smoke.json" -Encoding UTF8 +$smokeCode=$LASTEXITCODE +$search=(& $exe session search --query $Query --ui-output "$directory\expanded-ui.json" --timeout 30 | Out-String) | ConvertFrom-Json +$searchCode=$LASTEXITCODE +$rows=@($search.results) +$targetInQuery=@($rows | Where-Object {$_.Name -ceq $Target}).Count +$negativeCode=$null +if($targetInQuery -eq 0){ + $negative=(& $exe session open --query $Query --name $Target --timeout 30 | Out-String) | ConvertFrom-Json + $negativeCode=$LASTEXITCODE + $negativeAccepted=($negativeCode -ne 0 -and $negative.error -eq 'ControlNotFound') +}else{$negativeAccepted=$true} +$opened=(& $exe session open --query $Target --name $Target --timeout 30 | Out-String) | ConvertFrom-Json +$openCode=$LASTEXITCODE +$current=(& $exe session current --timeout 20 | Out-String) | ConvertFrom-Json +$currentCode=$LASTEXITCODE +& $exe inspect-ui --output "$directory\selected-ui.json" | Out-Null +$inspectCode=$LASTEXITCODE +$result=@{ + DoctorExit=$doctorCode;BaselineInspectExit=$baselineCode;SmokeExit=$smokeCode;SearchExit=$searchCode; + ExpandedCount=$rows.Count;ExpectedCount=$ExpectedCount;TargetInKeywordResults=$targetInQuery; + LocalCategoriesOnly=(@($rows | Where-Object {$_.Category -notin @('群聊','联系人','功能','最常使用')}).Count -eq 0); + MissingExactTargetRejected=$negativeAccepted;NegativeExit=$negativeCode; + OpenExit=$openCode;OpenError=$opened.error;OpenMessage=$opened.message;CurrentExit=$currentCode;InspectExit=$inspectCode; + OpenedTargetMatches=($opened.Name -ceq $Target);CurrentTargetMatches=($current.session.Name -ceq $Target); + GroupMessagesSent=0;BinarySha256=(Get-FileHash $exe -Algorithm SHA256).Hash +} +$result.Success=($doctorCode -eq 0 -and $baselineCode -eq 0 -and $smokeCode -eq 0 -and + $searchCode -eq 0 -and $rows.Count -eq $ExpectedCount -and $result.LocalCategoriesOnly -and + $negativeAccepted -and $openCode -eq 0 -and $currentCode -eq 0 -and $inspectCode -eq 0 -and + $result.OpenedTargetMatches -and $result.CurrentTargetMatches) +$result | ConvertTo-Json -Depth 5 | Set-Content "$directory\summary.json" -Encoding UTF8 +if(!$result.Success){exit 1} diff --git a/scripts/windows/Test-NamedListeners.ps1 b/scripts/windows/Test-NamedListeners.ps1 new file mode 100644 index 0000000..1ec6b44 --- /dev/null +++ b/scripts/windows/Test-NamedListeners.ps1 @@ -0,0 +1,39 @@ +param( + [Parameter(Mandatory=$true)][string]$Executable, + [Parameter(Mandatory=$true)][string]$OutputDirectory +) +$ErrorActionPreference='Stop' +$exe=(Resolve-Path $Executable).Path +$directory=[IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path $directory) { throw 'Use a new evidence directory.' } +[IO.Directory]::CreateDirectory($directory) | Out-Null +$targets=@('文件传输助手','消息测试专用群组') +$processes=@() +try { + for($i=0;$i -lt $targets.Count;$i++) { + $arguments=@('chat','monitor','--independent','--session',('"'+$targets[$i]+'"'), + '--seconds','8','--state-file',('"'+$directory+'\state-'+$i+'.json"'),'--timeout','20') + $process=Start-Process $exe -ArgumentList $arguments -PassThru -NoNewWindow ` + -RedirectStandardOutput "$directory\events-$i.jsonl" -RedirectStandardError "$directory\stderr-$i.txt" + $null=$process.Handle # Retain the process handle so PowerShell 5 can retrieve ExitCode after exit. + $processes+=$process + } + $checks=@() + for($i=0;$i -lt $processes.Count;$i++) { + $process=$processes[$i] + if (!$process.WaitForExit(30000)) { throw 'Named listener exceeded its deadline.' } + $exitCode=$process.ExitCode + $statePath="$directory\state-$i.json" + $state=if(Test-Path $statePath){ Get-Content $statePath -Raw | ConvertFrom-Json }else{$null} + & $exe inspect-ui --window-title $targets[$i] --output "$directory\ui-$i.json" --timeout 20 | Out-Null + $inspectCode=$LASTEXITCODE + $checks+=@{Index=$i;ExitCode=$exitCode;CheckpointSaved=($null -ne $state); + SessionMatches=($state.session -eq $targets[$i]);FingerprintCount=if($null -ne $state){@($state.fingerprints).Count}else{0};InspectExit=$inspectCode} + } + $passed=@($checks | Where-Object {$_.ExitCode -ne 0 -or !$_.CheckpointSaved -or !$_.SessionMatches -or $_.FingerprintCount -lt 1 -or $_.InspectExit -ne 0}).Count -eq 0 + $result=@{Checks=$checks;Passed=$passed;MessagesSent=0;Scope='Parallel named-window baseline only; not incoming-message delivery or endurance.'} + [IO.File]::WriteAllText("$directory\summary.json",($result | ConvertTo-Json -Depth 5),(New-Object Text.UTF8Encoding($false))) + if(!$passed){exit 2} +} finally { + foreach($process in $processes){ if(!$process.HasExited){$process.Kill();$process.WaitForExit()};$process.Dispose() } +} diff --git a/src/WxAgent.Core/UiNodeSnapshot.cs b/src/WxAgent.Core/UiNodeSnapshot.cs index b169b72..2c4a6a0 100644 --- a/src/WxAgent.Core/UiNodeSnapshot.cs +++ b/src/WxAgent.Core/UiNodeSnapshot.cs @@ -27,7 +27,13 @@ public static class UiSnapshotSanitizer "tool_bar_accessible" }; - public static string SanitizeName(string? value) => Sanitize(value, [], "text"); + private static readonly HashSet SafeSearchLabels = new(StringComparer.Ordinal) + { + "联系人", "群聊", "功能", "聊天记录", "网络搜索", "搜一搜", "返回", + "更多联系人", "更多群聊", "查看更多联系人", "查看更多群聊", "查看全部", "显示全部" + }; + + public static string SanitizeName(string? value) => Sanitize(value, SafeSearchLabels, "text"); public static string SanitizeAutomationId(string? value) => Sanitize(value, SafeAutomationIds, "id"); diff --git a/src/WxAgent.Core/WechatLocators.cs b/src/WxAgent.Core/WechatLocators.cs index 3771385..3468e7e 100644 --- a/src/WxAgent.Core/WechatLocators.cs +++ b/src/WxAgent.Core/WechatLocators.cs @@ -28,6 +28,7 @@ public static class WechatLocators public const string ChatBubbleItem = "chat_message_list.qt_scrollarea_viewport.chat_bubble_item_view"; public const string FileTransferAssistant = "文件传输助手"; public const string Search = "搜索"; + public const string SearchList = "search_list"; public const string Send = "发送"; public const string FileTransferAssistantSearchResult = "search_item_function_文件传输助手"; diff --git a/src/WxAgent.Core/WechatSearchPage.cs b/src/WxAgent.Core/WechatSearchPage.cs new file mode 100644 index 0000000..499b37e --- /dev/null +++ b/src/WxAgent.Core/WechatSearchPage.cs @@ -0,0 +1,74 @@ +using System.Text.RegularExpressions; + +namespace WxAgent.Core; + +public sealed record WechatSearchExpansion(string Category, int RowIndex, int ExpectedCount); +public sealed record WechatSearchPage( + IReadOnlyList Results, IReadOnlyList Expansions); + +public static class WechatSearchRules +{ + private static readonly Regex More = new(@"^查看全部\s*[((](\d+)[))]$", RegexOptions.CultureInvariant); + + public static bool IsLocalCategory(string? name) => name is "联系人" or "群聊" or "功能" or "最常使用"; + + public static bool IsExpansionLabel(string name) => More.IsMatch(name.Trim()); + + public static WechatSearchPage Parse( + IReadOnlyList<(string AutomationId, string AccessibleName)> rows, string query, string? expandedCategory = null) + { + if (expandedCategory is not null && expandedCategory is not ("联系人" or "群聊")) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Only local contacts and group chats may be expanded."); + var results = new List(); + var expansions = new List(); + var category = expandedCategory; + for (var index = 0; index < rows.Count; index++) + { + var row = rows[index]; + var name = row.AccessibleName.Trim(); + if (!row.AutomationId.StartsWith("search_item_", StringComparison.Ordinal)) + { + if (IsLocalCategory(name)) { category = name; continue; } + var more = More.Match(name); + if (more.Success && category is "联系人" or "群聊") + { + if (!int.TryParse(more.Groups[1].Value, out var count) || count is < 1 or > 10000) + throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Invalid local search result count."); + expansions.Add(new WechatSearchExpansion(category, index, count)); + continue; + } + // An unrecognized section must not inherit local trust from an expanded category. + category = null; + continue; + } + if (category is null || row.AutomationId.StartsWith("search_item_web_", StringComparison.Ordinal) + || row.AutomationId.StartsWith("search_item_online_", StringComparison.Ordinal)) continue; + var parsed = WechatSessionParser.ParseSearchResults([row], query).SingleOrDefault(); + if (parsed is null) continue; + var lines = row.AccessibleName.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var idName = row.AutomationId["search_item_".Length..]; + var displayName = lines.Contains(idName, StringComparer.Ordinal) ? idName + : parsed.Name == category ? lines.LastOrDefault(line => line != category) ?? string.Empty : parsed.Name; + if (displayName.Length == 0 || (category == "功能" && displayName != WechatLocators.FileTransferAssistant)) continue; + // The promoted local row uses the same name-based ID as contacts/groups. + // Do not admit arbitrary function, online, or future result families under this mixed header. + if (category == "最常使用" && row.AutomationId != "search_item_" + displayName + && row.AutomationId != WechatLocators.FileTransferAssistantSearchResult) continue; + // Preserve duplicates: two same-named local conversations must be reported as ambiguous, not silently merged. + results.Add(parsed with { Name = displayName, Category = category, IsExactMatch = string.Equals(displayName, query, StringComparison.Ordinal) }); + } + return new WechatSearchPage(results, expansions); + } + + public static WechatSessionSearchResult RequireExact(IReadOnlyList results, string name) + { + var matches = results.Where(result => IsLocalCategory(result.Category) + && string.Equals(result.Name, name, StringComparison.Ordinal)).ToArray(); + return matches.Length switch + { + 1 => matches[0], + 0 => throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The exact local conversation was not found."), + _ => throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Multiple local conversations have the same name; refusing an ambiguous selection.") + }; + } +} diff --git a/src/WxAgent.Host/Program.cs b/src/WxAgent.Host/Program.cs index 4c6002c..4809203 100644 --- a/src/WxAgent.Host/Program.cs +++ b/src/WxAgent.Host/Program.cs @@ -351,7 +351,7 @@ try case "session" when args[1] == "search": { var query = GetRequiredOption(args, "--query"); - var results = await WechatChatClient.SearchSessionsAsync(query, HasOption(args, "--exact"), cancellationToken); + var results = await WechatChatClient.SearchSessionsAsync(query, HasOption(args, "--exact"), cancellationToken, GetOption(args, "--ui-output")); WriteJson(new { query, results }); return 0; } @@ -362,7 +362,7 @@ try } case "session" when args[1] == "open": { - WriteJson(await WechatChatClient.OpenSessionAsync(GetRequiredOption(args, "--name"), cancellationToken)); + WriteJson(await WechatChatClient.OpenSessionAsync(GetRequiredOption(args, "--name"), cancellationToken, GetOption(args, "--query"))); return 0; } case "db" when args[1] == "scan": @@ -585,10 +585,10 @@ static int ValidateCommandLine(string[] values) ValidateOptions(values, 2, ["--timeout"], []); return 30; case "search": - ValidateOptions(values, 2, ["--query", "--timeout"], ["--exact"]); + ValidateOptions(values, 2, ["--query", "--ui-output", "--timeout"], ["--exact"]); return 30; case "open": - ValidateOptions(values, 2, ["--name", "--timeout"], []); + ValidateOptions(values, 2, ["--name", "--query", "--timeout"], []); return 30; default: throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown session command. Run WxAgent.Host help."); @@ -742,9 +742,9 @@ WxAgent.Host commands: chat monitor [--seconds 60] [--session ] [--independent] [--state-file ] [--include-content] [--timeout ] chat listen [--seconds 30] [--include-content] [--timeout ] session list [--timeout 30] - session search --query [--exact] [--timeout 30] + session search --query [--exact] [--ui-output ] [--timeout 30] session current [--timeout 30] - session open --name [--timeout 30] + session open --name [--query ] [--timeout 30] db scan [--data-root ] [--save] [--key-file ] [--timeout 120] db status [--key-file ] [--timeout 30] db query --account --database [--key-file ] [--timeout 30] diff --git a/src/WxAgent.Windows/WechatChatClient.Management.cs b/src/WxAgent.Windows/WechatChatClient.Management.cs index 8d74c64..78ff085 100644 --- a/src/WxAgent.Windows/WechatChatClient.Management.cs +++ b/src/WxAgent.Windows/WechatChatClient.Management.cs @@ -428,13 +428,7 @@ public static partial class WechatChatClient await WaitForSessionPageAsync(main, session, cancellationToken).ConfigureAwait(false); return; } - var search = main.FindAllDescendants().FirstOrDefault(element => - SafeControlType(element) == ControlType.Edit && SafeName(element) == WechatLocators.Search) - ?? throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Search box was not found."); - search.AsTextBox().Text = session; - await Task.Delay(500, cancellationToken).ConfigureAwait(false); - ClickNamed(main, session); - await WaitForSessionPageAsync(main, session, cancellationToken).ConfigureAwait(false); + await OpenLocalSearchSessionAsync(main, session, cancellationToken).ConfigureAwait(false); } private static async Task WaitForSessionPageAsync(AutomationElement main, string session, CancellationToken cancellationToken) diff --git a/src/WxAgent.Windows/WechatChatClient.Search.cs b/src/WxAgent.Windows/WechatChatClient.Search.cs new file mode 100644 index 0000000..3c62c40 --- /dev/null +++ b/src/WxAgent.Windows/WechatChatClient.Search.cs @@ -0,0 +1,91 @@ +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public static partial class WechatChatClient +{ + private static FlaUI.Core.AutomationElements.TextBox GetSearchBox(AutomationElement main) => + (main.FindFirstDescendant(cf => cf.ByControlType(ControlType.Edit).And(cf.ByName(WechatLocators.Search))) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Search input was not found.")).AsTextBox(); + + private static AutomationElement[] GetSearchRows(AutomationElement main) => + (FindByAutomationId(main, WechatLocators.SearchList) + ?? throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Local search list was not found.")) + .FindAllChildren(cf => cf.ByControlType(ControlType.ListItem)); + + private static WechatSearchPage ReadSearchPage(AutomationElement[] rows, string query, string? category = null) => + WechatSearchRules.Parse(rows.Select(row => (SafeAutomationId(row), SafeName(row))).ToArray(), query, category); + + private static async Task ResetLocalSearchAsync(AutomationElement main, string query, CancellationToken cancellationToken) + { + GetSearchBox(main).Text = string.Empty; + GetSearchBox(main).Text = query; + if (!string.Equals(GetSearchBox(main).Text, query, StringComparison.Ordinal)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "Search input did not retain the exact query."); + await Task.Delay(500, cancellationToken).ConfigureAwait(false); + return ReadSearchPage(GetSearchRows(main), query); + } + + private static async Task ExpandLocalCategoryAsync( + AutomationElement main, string query, WechatSearchExpansion expansion, CancellationToken cancellationToken) + { + var rows = GetSearchRows(main); + var current = ReadSearchPage(rows, query).Expansions.SingleOrDefault(item => item.Category == expansion.Category) + ?? throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Local expansion control changed before activation."); + // This row was classified under a local header, not found by a global keyword/name search. + rows[current.RowIndex].Click(); + for (var attempt = 0; attempt < 25; attempt++) + { + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + var page = ReadSearchPage(GetSearchRows(main), query, current.Category); + if (page.Results.Count(result => result.Category == current.Category) >= current.ExpectedCount + && !page.Expansions.Any(item => item.Category == current.Category)) return page; + } + throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Not all local search results became available; refusing a partial selection."); + } + + private static async Task> SearchLocalSessionsCoreAsync( + AutomationElement main, string query, CancellationToken cancellationToken) + { + var overview = await ResetLocalSearchAsync(main, query, cancellationToken).ConfigureAwait(false); + var results = overview.Results.ToList(); + foreach (var expansion in overview.Expansions) + { + await ResetLocalSearchAsync(main, query, cancellationToken).ConfigureAwait(false); + var full = await ExpandLocalCategoryAsync(main, query, expansion, cancellationToken).ConfigureAwait(false); + results.RemoveAll(result => result.Category == expansion.Category); + results.AddRange(full.Results.Where(result => result.Category == expansion.Category)); + } + return results; + } + + private static async Task OpenLocalSearchSessionAsync(AutomationElement main, string name, CancellationToken cancellationToken, string? query = null) + { + query ??= name; + var results = await SearchLocalSessionsCoreAsync(main, query, cancellationToken).ConfigureAwait(false); + var selected = WechatSearchRules.RequireExact(results, name); + var page = await ResetLocalSearchAsync(main, query, cancellationToken).ConfigureAwait(false); + var expansion = page.Expansions.SingleOrDefault(item => item.Category == selected.Category); + if (expansion is not null) + await ExpandLocalCategoryAsync(main, query, expansion, cancellationToken).ConfigureAwait(false); + var rows = GetSearchRows(main); + var current = ReadSearchPage(rows, query, expansion?.Category); + var fresh = WechatSearchRules.RequireExact(current.Results, name); + if (fresh.Category != selected.Category) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Local search category changed before selection."); + var matches = rows.Where(row => SafeAutomationId(row) == fresh.AutomationId && SessionNameMatches(SafeName(row), name)).ToArray(); + if (matches.Length != 1) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Local search target is no longer unique."); + if (matches[0].IsOffscreen) + { + var scroll = matches[0].Patterns.ScrollItem.PatternOrDefault + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Local result is offscreen and cannot be scrolled into view."); + scroll.ScrollIntoView(); + } + matches[0].Click(); + await WaitForSessionPageAsync(main, name, cancellationToken).ConfigureAwait(false); + return fresh; + } +} diff --git a/src/WxAgent.Windows/WechatChatClient.cs b/src/WxAgent.Windows/WechatChatClient.cs index 04c1785..1161fa6 100644 --- a/src/WxAgent.Windows/WechatChatClient.cs +++ b/src/WxAgent.Windows/WechatChatClient.cs @@ -40,7 +40,8 @@ public static partial class WechatChatClient public static async Task> SearchSessionsAsync( string query, bool exactOnly, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string? uiOutput = null) { ArgumentException.ThrowIfNullOrWhiteSpace(query); await CommandQueue.WaitAsync(cancellationToken); @@ -55,18 +56,9 @@ public static partial class WechatChatClient var previous = box.Text; try { - box.Text = query; - if (!string.Equals(box.Text, query, StringComparison.Ordinal)) - { - throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The search box did not contain the exact query."); - } - - await Task.Delay(500, cancellationToken); - var results = WechatSessionParser.ParseSearchResults( - window.FindAllDescendants() - .Where(element => SafeControlType(element) == ControlType.ListItem) - .Select(element => (SafeAutomationId(element), SafeName(element))), - query); + var results = await SearchLocalSessionsCoreAsync(window, query, cancellationToken).ConfigureAwait(false); + if (uiOutput is not null) + await WechatUiInspector.CaptureAsync(uiOutput, cancellationToken).ConfigureAwait(false); return exactOnly ? results.Where(result => result.IsExactMatch).ToArray() : results; } finally @@ -90,14 +82,20 @@ public static partial class WechatChatClient : new WechatSessionSnapshot(name, SafeAutomationId(element!), true)); }, cancellationToken); - public static async Task OpenSessionAsync(string name, CancellationToken cancellationToken) + public static async Task OpenSessionAsync(string name, CancellationToken cancellationToken, string? query = null) { ArgumentException.ThrowIfNullOrWhiteSpace(name); + if (query is not null) ArgumentException.ThrowIfNullOrWhiteSpace(query); await CommandQueue.WaitAsync(cancellationToken); try { using var automation = new UIA3Automation(); var window = AttachWindow(automation, cancellationToken); + if (query is not null) + { + var selected = await OpenLocalSearchSessionAsync(window, name, cancellationToken, query).ConfigureAwait(false); + return new WechatSessionSnapshot(name, selected.AutomationId, true); + } if (SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)) == name) { return new WechatSessionSnapshot(name, SafeAutomationId(FindByAutomationId(window, WechatLocators.CurrentChatName)!), true); @@ -106,23 +104,8 @@ public static partial class WechatChatClient var target = FindByAutomationId(window, "session_item_" + name); if (target is null) { - var search = window.FindAllDescendants().FirstOrDefault(element => - SafeControlType(element) == ControlType.Edit && SafeName(element) == WechatLocators.Search) - ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat search box was not found."); - search.AsTextBox().Text = name; - var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5); - while (DateTimeOffset.UtcNow < deadline && target is null) - { - cancellationToken.ThrowIfCancellationRequested(); - target = window.FindAllDescendants().FirstOrDefault(element => - SafeAutomationId(element).StartsWith("search_item_", StringComparison.Ordinal) && SessionNameMatches(SafeName(element), name)); - if (target is null) await Task.Delay(150, cancellationToken); - } - } - - if (target is null) - { - throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The requested session was not found."); + var selected = await OpenLocalSearchSessionAsync(window, name, cancellationToken).ConfigureAwait(false); + return new WechatSessionSnapshot(name, selected.AutomationId, true); } ClickCenter(target); @@ -400,6 +383,9 @@ public static partial class WechatChatClient IDisposable? subscription = null; var connected = false; nint subscribedWindow = 0; + var bindingAttempted = false; + var observedSnapshot = false; + Exception? lastReadError = null; var announceReconnect = recoveredScan; var elapsed = System.Diagnostics.Stopwatch.StartNew(); using var readDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -416,6 +402,7 @@ public static partial class WechatChatClient await CommandQueue.WaitAsync(readDeadline.Token); try { + bindingAttempted = true; window = await BindListeningWindowAsync(automation, session, independentWindow, readDeadline.Token).ConfigureAwait(false); var handle = window.Properties.NativeWindowHandle.Value; if (subscribedWindow != 0 && handle != subscribedWindow) @@ -440,6 +427,7 @@ public static partial class WechatChatClient if (!IsListeningSession(window, session, independentWindow)) throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener session changed during snapshot."); visible = snapshot; + observedSnapshot = true; snapshotObserved?.Invoke(); } finally @@ -455,8 +443,9 @@ public static partial class WechatChatClient { throw; } - catch + catch (Exception exception) { + lastReadError = exception; visible = null; connected = false; subscription?.Dispose(); @@ -551,6 +540,9 @@ public static partial class WechatChatClient { subscription?.Dispose(); } + if (bindingAttempted && !observedSnapshot) + throw new WxAgentException(lastReadError is WxAgentException error ? error.Code : WxAgentErrorCode.ControlNotFound, + $"Listener never obtained a valid snapshot (stage: {lastReadError?.TargetSite?.Name ?? "initial binding"}).", lastReadError); } private static AutomationElement AttachWindow(UIA3Automation automation, CancellationToken cancellationToken) diff --git a/tests/WxAgent.Core.Tests/WechatSearchRulesTests.cs b/tests/WxAgent.Core.Tests/WechatSearchRulesTests.cs new file mode 100644 index 0000000..6d26cb2 --- /dev/null +++ b/tests/WxAgent.Core.Tests/WechatSearchRulesTests.cs @@ -0,0 +1,80 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatSearchRulesTests +{ + [Fact] + public void CollapsedGroupsExposeALocalExpansion_NotANetworkKeyword() + { + var page = WechatSearchRules.Parse([ + ("", "群聊"), ("search_item_技术交流群", "群聊\n技术交流群"), ("", "查看全部(10)"), + ("", "网络搜索"), ("search_item_web_交流", "网络搜索\n交流"), ("", "交流")], "交流"); + Assert.Single(page.Results); + Assert.Equal("技术交流群", page.Results[0].Name); + Assert.False(page.Results[0].IsExactMatch); + Assert.Equal("技术交流群", WechatSearchRules.RequireExact(page.Results, "技术交流群").Name); + Assert.Equal(new WechatSearchExpansion("群聊", 2, 10), Assert.Single(page.Expansions)); + Assert.Throws(() => WechatSearchRules.RequireExact(page.Results, "交流")); + } + + [Fact] + public void GroupsAndContactsAreBothLocal_AndDuplicateNamesRemainAmbiguous() + { + var page = WechatSearchRules.Parse([ + ("", "联系人"), ("search_item_交流", "联系人\n交流"), + ("", "群聊"), ("search_item_交流", "群聊\n交流")], "交流"); + Assert.Equal(2, page.Results.Count); + Assert.Equal(WxAgentErrorCode.InvalidOperationState, + Assert.Throws(() => WechatSearchRules.RequireExact(page.Results, "交流")).Code); + } + + [Fact] + public void ExpandedLocalPageStillExcludesNetworkAndHistory() + { + var page = WechatSearchRules.Parse([ + ("search_item_技术交流群", "技术交流群"), ("search_item_web_交流", "交流"), + ("", "聊天记录"), ("search_item_历史交流", "交流")], "交流", "群聊"); + Assert.Equal("技术交流群", Assert.Single(page.Results).Name); + Assert.Empty(page.Expansions); + } + + [Fact] + public void ExpandedPageDoesNotTrustUnknownSectionsOrTheirSearchIds() + { + var page = WechatSearchRules.Parse([ + ("search_item_技术交流群", "技术交流群"), + ("", "未知类别"), ("search_item_new_kind_交流", "交流")], "交流", "群聊"); + Assert.Equal("技术交流群", Assert.Single(page.Results).Name); + } + + [Fact] + public void PromotedLocalConversationRemainsSelectableButUnknownFamiliesDoNot() + { + var page = WechatSearchRules.Parse([ + ("", "最常使用"), ("search_item_技术交流群", "技术交流群"), + ("search_item_function_交流", "交流"), ("search_item_online_交流", "交流"), + ("search_item_future_交流", "交流")], "交流"); + Assert.Equal("技术交流群", WechatSearchRules.RequireExact(page.Results, "技术交流群").Name); + Assert.Single(page.Results); + } + + [Fact] + public void ACategoryCaptionIsNotTheConversationName() + { + var page = WechatSearchRules.Parse([("", "群聊"), ("search_item_技术群", "群聊\n技术群")], "群聊"); + Assert.Equal("技术群", Assert.Single(page.Results).Name); + Assert.False(page.Results[0].IsExactMatch); + } + + [Fact] + public void WebExpansionAndUnclassifiedSearchItemsAreNeverTargets() + { + var page = WechatSearchRules.Parse([ + ("search_item_交流", "交流"), ("", "网络搜索"), ("", "查看全部(99)")], "交流"); + Assert.Empty(page.Results); + Assert.Empty(page.Expansions); + Assert.Throws(() => WechatSearchRules.Parse([], "交流", "网络搜索")); + } +}