fix: expand local session results and reject unsafe search targets
This commit is contained in:
@@ -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}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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_文件传输助手";
|
||||
|
||||
|
||||
@@ -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.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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([], "交流", "网络搜索"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user