fix: expand local session results and reject unsafe search targets

This commit is contained in:
2026-09-06 01:24:50 +08:00
parent 93f4583737
commit c35a4a1051
16 changed files with 557 additions and 46 deletions
@@ -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 <exe> -OutputDirectory <新目录>`
- `scripts/windows/Test-NamedListeners.ps1 -Executable <exe> -OutputDirectory <新目录>`
- Core:72/72 测试通过;全解决方案 Release 构建和 Windows self-contained 发布均为 0 警告、0 错误。
- 脱敏验收记录:`evidence/Local-Search-20260905/`。失败的早期候选包保留在本地 `artifacts/`,未冒充最终通过证据。
后续仍需完成已有计划中的 M2 连续发送、M3 新消息/恢复验收及其他阶段的剩余项目;M6 8/24/72 小时正式验收尚未完成。
@@ -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
}
]
}
@@ -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": []
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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}
+39
View File
@@ -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() }
}
+7 -1
View File
@@ -27,7 +27,13 @@ public static class UiSnapshotSanitizer
"tool_bar_accessible"
};
public static string SanitizeName(string? value) => Sanitize(value, [], "text");
private static readonly HashSet<string> SafeSearchLabels = new(StringComparer.Ordinal)
{
"联系人", "群聊", "功能", "聊天记录", "网络搜索", "搜一搜", "返回",
"更多联系人", "更多群聊", "查看更多联系人", "查看更多群聊", "查看全部", "显示全部"
};
public static string SanitizeName(string? value) => Sanitize(value, SafeSearchLabels, "text");
public static string SanitizeAutomationId(string? value) => Sanitize(value, SafeAutomationIds, "id");
+1
View File
@@ -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_文件传输助手";
+74
View File
@@ -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<WechatSessionSearchResult> Results, IReadOnlyList<WechatSearchExpansion> 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<WechatSessionSearchResult>();
var expansions = new List<WechatSearchExpansion>();
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<WechatSessionSearchResult> 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.")
};
}
}
+6 -6
View File
@@ -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 <name>] [--independent] [--state-file <path>] [--include-content] [--timeout <seconds>]
chat listen [--seconds 30] [--include-content] [--timeout <seconds>]
session list [--timeout 30]
session search --query <text> [--exact] [--timeout 30]
session search --query <text> [--exact] [--ui-output <path>] [--timeout 30]
session current [--timeout 30]
session open --name <session-name> [--timeout 30]
session open --name <session-name> [--query <keyword>] [--timeout 30]
db scan [--data-root <xwechat_files>] [--save] [--key-file <path>] [--timeout 120]
db status [--key-file <path>] [--timeout 30]
db query --account <fingerprint> --database <relative-path> [--key-file <path>] [--timeout 30]
@@ -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)
@@ -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<WechatSearchPage> 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<WechatSearchPage> 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<IReadOnlyList<WechatSessionSearchResult>> 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<WechatSessionSearchResult> 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;
}
}
+24 -32
View File
@@ -40,7 +40,8 @@ public static partial class WechatChatClient
public static async Task<IReadOnlyList<WechatSessionSearchResult>> 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<WechatSessionSnapshot> OpenSessionAsync(string name, CancellationToken cancellationToken)
public static async Task<WechatSessionSnapshot> 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)
@@ -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<WxAgentException>(() => 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<WxAgentException>(() => 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<WxAgentException>(() => WechatSearchRules.Parse([], "交流", "网络搜索"));
}
}