diff --git a/docs/validation/Navigation-2026-09-06.md b/docs/validation/Navigation-2026-09-06.md new file mode 100644 index 0000000..882ca0e --- /dev/null +++ b/docs/validation/Navigation-2026-09-06.md @@ -0,0 +1,29 @@ +# 导航增量验收 — 2026-09-06 + +## 本次实现 + +- `session scroll --direction up|down --pages N`:会话列表滚动;默认不输出联系人名称,`--include-content` 显式开启。 +- `chat latest [--session ]`:回到指定会话的最新可见消息。 +- `chat locate --fingerprint <可见消息指纹> [--session ] [--scrolls N]`:有界向上搜索并将匹配行带入视口。 +- `chat history` 支持 `--session`;两个历史读取入口复用共享滚动与回到最新逻辑,不再按累计滚轮距离猜测返回位置。 +- `chat merged-open` 是可选的卡片窗口导航,仅返回新窗口句柄,**不负责内容提取**。合并内容的正式读取由独立的 `db merged` 实现。 +- 增加 `smoke --read-only`,明确返回 `sendSkipped=true`、`confirmed=false`;只读验证不发送标记。 + +所有 UI 操作使用现有跨进程命令锁、输入桌面检查和会话核验。先使用 UIA Scroll/ScrollItem,缺失时使用集中维护的滚轮兜底;不点击输入框、不发送或转发消息。 + +## 验证 + +- 微信 `4.1.13.63`,Windows 真机 `10.1.1.101` 的现有已登录会话。 +- 发布目录:`C:\Users\Rogee\wx-agent\releases\navigation-20260906T113948`。 +- Linux Core 测试 **124/124**,完整 Release build **0 warnings / 0 errors**。 +- `Test-Navigation.ps1` 最终 **10/10** 通过,发送次数 **0**:doctor、inspect-ui、只读 smoke、会话下滚/上滚、回到最新、历史读取、定位、再次回到最新、打开合并卡片。 +- 定位测试确实向上滚动 **2 次**后找到指定指纹,不是仅匹配当前页。 +- 第一次脚本将 JSON 枚举误作数字,7 步后主动停止;修正为 `Text` / `Merge` 后完成第二次只读验证。未把第一次失败记为通过。 + +脱敏证据:[`evidence/Navigation-20260906/`](evidence/Navigation-20260906/)。不提交联系人名称、消息正文或完整 UI 树。 + +## 边界 + +- 指纹是现有 UIA 可见快照指纹,不是数据库 server ID;相同文本重复消息在不同视口的身份仍有局限,不能据此进行不可逆操作。 +- 无原生滚动百分比时,“回到最新”依赖连续稳定视口,不宣称数据库历史完整性。定位达到滚动上限返回 `ResultUnconfirmed`;视口停止变化而未找到返回 `ControlNotFound`。 +- 本增量未实现“下一个未读会话”。会话列表缺少已验证的未读标记时,不从预览文本猜测未读状态。 diff --git a/docs/validation/evidence/Navigation-20260906/locate.json b/docs/validation/evidence/Navigation-20260906/locate.json new file mode 100644 index 0000000..e435576 --- /dev/null +++ b/docs/validation/evidence/Navigation-20260906/locate.json @@ -0,0 +1,10 @@ +{ + "Scrolls": 2, + "message": { + "Fingerprint": "1e59c51f8086b6ec6d80e34a16daabc0359f0fd4f4633289113e653b9b0ab166", + "Type": "Text", + "length": 61, + "content": null, + "quote": null + } +} diff --git a/docs/validation/evidence/Navigation-20260906/sessions-down.json b/docs/validation/evidence/Navigation-20260906/sessions-down.json new file mode 100644 index 0000000..1067b51 --- /dev/null +++ b/docs/validation/evidence/Navigation-20260906/sessions-down.json @@ -0,0 +1,56 @@ +{ + "Scrolls": 2, + "ViewportChanged": true, + "sessions": [ + { + "name": null, + "nameId": "sha256:1DC65E74B01A64FA", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:B0E5330510C53A70", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:4DF34FAE92A9BB26", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:2EF8DCA410614184", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:2092711F288D71DA", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:AD8DF8A5CA6DCC46", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:BA4121C435EF5D04", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:563C6AC6EFB52329", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:5F1E8BDE6155432A", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:D93D7F06AE52537F", + "IsCurrent": false + } + ] +} diff --git a/docs/validation/evidence/Navigation-20260906/sessions-up.json b/docs/validation/evidence/Navigation-20260906/sessions-up.json new file mode 100644 index 0000000..1c531ad --- /dev/null +++ b/docs/validation/evidence/Navigation-20260906/sessions-up.json @@ -0,0 +1,56 @@ +{ + "Scrolls": 2, + "ViewportChanged": true, + "sessions": [ + { + "name": null, + "nameId": "sha256:28032CB51B5D5918", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:EC654065E79D4554", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:C7CE04276AC5A9C4", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:9F775FAD499E92FF", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:2B55CD2E01D1E400", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:A5B67004830C26E7", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:8ABDA031D8E42C4E", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:68C6E1577C7F5576", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:BFE36215E3C4FDAA", + "IsCurrent": false + }, + { + "name": null, + "nameId": "sha256:1DC65E74B01A64FA", + "IsCurrent": false + } + ] +} diff --git a/docs/validation/evidence/Navigation-20260906/smoke.json b/docs/validation/evidence/Navigation-20260906/smoke.json new file mode 100644 index 0000000..73d88d3 --- /dev/null +++ b/docs/validation/evidence/Navigation-20260906/smoke.json @@ -0,0 +1,11 @@ +{ + "scope": "M1", + "doctorErrors": [], + "output": "C:\\Users\\Rogee\\wx-agent\\releases\\navigation-20260906T113948\\artifacts\\ui-tree.json", + "nodes": 154, + "sanitized": true, + "sentTo": null, + "Fingerprint": null, + "sendSkipped": true, + "confirmed": false +} diff --git a/docs/validation/evidence/Navigation-20260906/summary.json b/docs/validation/evidence/Navigation-20260906/summary.json new file mode 100644 index 0000000..5c5af59 --- /dev/null +++ b/docs/validation/evidence/Navigation-20260906/summary.json @@ -0,0 +1,47 @@ +{ + "Checks": [ + { + "Stage": "doctor", + "ExitCode": 0 + }, + { + "Stage": "inspect-ui", + "ExitCode": 0 + }, + { + "Stage": "smoke", + "ExitCode": 0 + }, + { + "Stage": "sessions-down", + "ExitCode": 0 + }, + { + "Stage": "sessions-up", + "ExitCode": 0 + }, + { + "Stage": "latest-before", + "ExitCode": 0 + }, + { + "Stage": "history", + "ExitCode": 0 + }, + { + "Stage": "locate", + "ExitCode": 0 + }, + { + "Stage": "latest-after", + "ExitCode": 0 + }, + { + "Stage": "merged-open", + "ExitCode": 0 + } + ], + "SendsAttempted": 0, + "BinarySha256": "F443EC6EF044F66349E55EBEB4ED0109EA13C96D8216079AEAEE3A19DF9A32DA", + "Success": true +} diff --git a/scripts/windows/Test-Navigation.ps1 b/scripts/windows/Test-Navigation.ps1 new file mode 100644 index 0000000..4743359 --- /dev/null +++ b/scripts/windows/Test-Navigation.ps1 @@ -0,0 +1,40 @@ +param([Parameter(Mandatory=$true)][string]$Executable,[Parameter(Mandatory=$true)][string]$OutputDirectory) +$ErrorActionPreference='Stop' +$exe=(Resolve-Path $Executable).Path +if(Test-Path $OutputDirectory){throw 'Use a fresh evidence directory.'} +$out=(New-Item -ItemType Directory $OutputDirectory).FullName +$checks=New-Object System.Collections.Generic.List[object] +function Check([string]$Name,[string[]]$Arguments){ + $raw=(& $exe @Arguments 2> "$out\$Name.stderr.txt" | Out-String) + $code=$LASTEXITCODE + $raw | Set-Content "$out\$Name.json" -Encoding UTF8 + $checks.Add(@{Stage=$Name;ExitCode=$code}) + if($code -ne 0){throw "$Name failed; stopped."} + return ($raw | ConvertFrom-Json) +} +try { + $null=Check 'doctor' @('doctor','--timeout','30') + $null=Check 'inspect-ui' @('inspect-ui','--output',"$out\ui-tree.json",'--timeout','30') + $smoke=Check 'smoke' @('smoke','--read-only','--timeout','30') + if(!$smoke.sendSkipped -or $smoke.confirmed){throw 'Expected a strictly read-only smoke.'} + $null=Check 'sessions-down' @('session','scroll','--direction','down','--pages','2','--timeout','30') + $null=Check 'sessions-up' @('session','scroll','--direction','up','--pages','2','--timeout','30') + $latest=Check 'latest-before' @('chat','latest','--timeout','60') + $history=Check 'history' @('chat','history','--limit','50','--scrolls','4','--timeout','60') + $target=@($history.messages | Where-Object {$_.Type -eq 'Text'} | Select-Object -First 1) + if($target.Count -ne 1){throw 'No text message available for read-only locate validation.'} + $located=Check 'locate' @('chat','locate','--fingerprint',$target[0].Fingerprint,'--scrolls','10','--timeout','60') + if($located.message.Fingerprint -cne $target[0].Fingerprint){throw 'Locate returned a different fingerprint.'} + $latest=Check 'latest-after' @('chat','latest','--timeout','60') + $merged=@($latest.messages | Where-Object {$_.Type -eq 'Merge'} | Select-Object -Last 1) + if($merged.Count -ne 1){throw 'No merged-chat card was found in the latest File Transfer Assistant messages.'} + @{Fingerprint=$merged[0].Fingerprint} | ConvertTo-Json | Set-Content "$out\merged-target.json" -Encoding UTF8 + $opened=Check 'merged-open' @('chat','merged-open','--fingerprint',$merged[0].Fingerprint,'--timeout','30') + if(!$opened.windowHandle){throw 'No merged-chat window handle returned.'} +} catch { + @{Message=$_.Exception.Message;Line=$_.InvocationInfo.ScriptLineNumber} | ConvertTo-Json | Set-Content "$out\error.json" -Encoding UTF8 +} finally { + $success=($checks.Count -eq 10 -and @($checks | Where-Object {$_.ExitCode -ne 0}).Count -eq 0) + @{Success=$success;Checks=@($checks.ToArray());SendsAttempted=0;BinarySha256=(Get-FileHash $exe -Algorithm SHA256).Hash} | ConvertTo-Json -Depth 6 | Set-Content "$out\summary.json" -Encoding UTF8 +} +if(!$success){exit 1} diff --git a/src/WxAgent.Core/WechatNavigation.cs b/src/WxAgent.Core/WechatNavigation.cs new file mode 100644 index 0000000..df98112 --- /dev/null +++ b/src/WxAgent.Core/WechatNavigation.cs @@ -0,0 +1,27 @@ +namespace WxAgent.Core; + +public enum WechatScrollDirection { Up, Down } + +public sealed record WechatSessionScrollResult( + int Scrolls, bool ViewportChanged, IReadOnlyList Sessions); + +public sealed record WechatMessagePosition(ChatMessageSnapshot Message, int Scrolls); + +public static class WechatNavigationRules +{ + public static void ValidateScrollLimit(int pages) + { + if (pages is < 1 or > 100) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Scroll limit must be between 1 and 100."); + } + + public static ChatMessageSnapshot? FindMessage(IReadOnlyList messages, string fingerprint) + { + if (fingerprint.Length != 64 || !fingerprint.All(char.IsAsciiHexDigit)) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "A 64-character visible-message fingerprint is required."); + var matches = messages.Where(message => string.Equals(message.Fingerprint, fingerprint, StringComparison.OrdinalIgnoreCase)).Take(2).ToArray(); + if (matches.Length > 1) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "More than one visible message matches the fingerprint."); + return matches.SingleOrDefault(); + } +} diff --git a/src/WxAgent.Host/Program.cs b/src/WxAgent.Host/Program.cs index ae21d68..acedaaa 100644 --- a/src/WxAgent.Host/Program.cs +++ b/src/WxAgent.Host/Program.cs @@ -74,8 +74,13 @@ try var output = GetOption(args, "--output") ?? Path.Combine("artifacts", "ui-tree.json"); var report = WechatDoctor.Run(cancellationToken); var snapshot = await WechatUiInspector.CaptureAsync(output, cancellationToken); - var marker = $"wxagent-smoke-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"; - var confirmed = await WechatChatClient.SendTextAsync(marker, cancellationToken); + var readOnly = HasOption(args, "--read-only"); + ChatMessageSnapshot? confirmed = null; + if (!readOnly && report.Errors.Count == 0) + { + var marker = $"wxagent-smoke-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"; + confirmed = await WechatChatClient.SendTextAsync(marker, cancellationToken); + } WriteJson(new { scope = "M1", @@ -83,9 +88,10 @@ try output = Path.GetFullPath(output), nodes = CountNodes(snapshot), sanitized = true, - sentTo = "file-transfer-assistant", - confirmed.Fingerprint, - confirmed = true + sentTo = confirmed is null ? null : "file-transfer-assistant", + Fingerprint = confirmed?.Fingerprint, + sendSkipped = readOnly, + confirmed = confirmed is not null }); return report.Errors.Count == 0 ? 0 : 2; } @@ -321,10 +327,32 @@ try var includeContent = HasOption(args, "--include-content"); var limit = GetPositiveIntOption(args, "--limit", 100, 1000); var scrolls = GetPositiveIntOption(args, "--scrolls", 10, 100, allowZero: true); - var messages = await WechatChatClient.ReadHistoryAsync(limit, scrolls, cancellationToken); + var messages = await WechatChatClient.ReadHistoryAsync(limit, scrolls, cancellationToken, + GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant); WriteJson(new { messages = messages.Select(message => ToMessageOutput(message, includeContent)) }); return 0; } + case "chat" when args[1] == "latest": + { + var messages = await WechatChatClient.GoToLatestAsync(GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant, + GetPositiveIntOption(args, "--scrolls", 100, 100), cancellationToken); + WriteJson(new { messages = messages.Select(message => ToMessageOutput(message, HasOption(args, "--include-content"))) }); + return 0; + } + case "chat" when args[1] == "merged-open": + { + var windowHandle = await WechatChatClient.OpenMergedChatAsync(GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant, + GetRequiredOption(args, "--fingerprint"), cancellationToken); + WriteJson(new { windowHandle }); + return 0; + } + case "chat" when args[1] == "locate": + { + var result = await WechatChatClient.LocateMessageAsync(GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant, + GetRequiredOption(args, "--fingerprint"), GetPositiveIntOption(args, "--scrolls", 30, 100), cancellationToken); + WriteJson(new { result.Scrolls, message = ToMessageOutput(result.Message, HasOption(args, "--include-content")) }); + return 0; + } case "chat" when args[1] == "monitor": { var includeContent = HasOption(args, "--include-content"); @@ -359,6 +387,18 @@ try WriteJson(new { sessions = await WechatChatClient.ListVisibleSessionsAsync(cancellationToken) }); return 0; } + case "session" when args[1] == "scroll": + { + var direction = GetRequiredOption(args, "--direction") switch + { + "up" => WechatScrollDirection.Up, + "down" => WechatScrollDirection.Down, + _ => throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Direction must be up or down.") + }; + var result = await WechatChatClient.ScrollSessionsAsync(direction, GetPositiveIntOption(args, "--pages", 1, 100), cancellationToken); + WriteJson(new { result.Scrolls, result.ViewportChanged, sessions = result.Sessions.Select(session => new { name = HasOption(args, "--include-content") ? session.Name : null, nameId = MaskIdentifier(session.Name), session.IsCurrent }) }); + return 0; + } case "session" when args[1] == "search": { var query = GetRequiredOption(args, "--query"); @@ -620,7 +660,7 @@ static int ValidateCommandLine(string[] values) if (values[0] == "smoke") { - ValidateOptions(values, 1, ["--output", "--timeout"], []); + ValidateOptions(values, 1, ["--output", "--timeout"], ["--read-only"]); return 30; } @@ -693,7 +733,16 @@ static int ValidateCommandLine(string[] values) ValidateOptions(values, 2, ["--limit", "--timeout"], ["--include-content"]); return 30; case "history": - ValidateOptions(values, 2, ["--limit", "--scrolls", "--timeout"], ["--include-content"]); + ValidateOptions(values, 2, ["--session", "--limit", "--scrolls", "--timeout"], ["--include-content"]); + return 60; + case "latest": + ValidateOptions(values, 2, ["--session", "--scrolls", "--timeout"], ["--include-content"]); + return 60; + case "merged-open": + ValidateOptions(values, 2, ["--session", "--fingerprint", "--timeout"], []); + return 60; + case "locate": + ValidateOptions(values, 2, ["--session", "--fingerprint", "--scrolls", "--timeout"], ["--include-content"]); return 60; case "monitor": ValidateOptions(values, 2, ["--seconds", "--state-file", "--session", "--timeout"], ["--include-content", "--independent"]); @@ -714,6 +763,9 @@ static int ValidateCommandLine(string[] values) case "current": ValidateOptions(values, 2, ["--timeout"], []); return 30; + case "scroll": + ValidateOptions(values, 2, ["--direction", "--pages", "--timeout"], ["--include-content"]); + return 60; case "search": ValidateOptions(values, 2, ["--query", "--ui-output", "--timeout"], ["--exact"]); return 30; @@ -863,7 +915,7 @@ WxAgent.Host commands: doctor [--timeout 30] diagnose --output [--baseline ] [--timeout 60] inspect-ui --output [--window-title ] [--timeout 30] - smoke [--output <path>] [--timeout 30] + smoke [--output <path>] [--read-only] [--timeout 30] m4-smoke [--timeout 60] m5-smoke [--timeout 60] tray-status [--timeout 30] @@ -883,10 +935,14 @@ WxAgent.Host commands: chat send-image --path <image> [--session <name>] [--timeout 60] chat mention --session <name> --member <display-name> --text <text> --confirm CONFIRM [--timeout 60] chat read [--limit 20] [--include-content] [--timeout 30] - chat history [--limit 100] [--scrolls 10] [--include-content] [--timeout 60] + chat history [--session <name>] [--limit 100] [--scrolls 10] [--include-content] [--timeout 60] + chat latest [--session <name>] [--scrolls 100] [--include-content] [--timeout 60] + chat merged-open --fingerprint <visible-message-fingerprint> [--session <name>] [--timeout 60] + chat locate --fingerprint <visible-message-fingerprint> [--session <name>] [--scrolls 30] [--include-content] [--timeout 60] 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 scroll --direction <up|down> [--pages 1] [--include-content] [--timeout 60] session search --query <text> [--exact] [--ui-output <path>] [--timeout 30] session current [--timeout 30] session open --name <session-name> [--query <keyword>] [--timeout 30] diff --git a/src/WxAgent.Windows/WechatChatClient.Management.cs b/src/WxAgent.Windows/WechatChatClient.Management.cs index f0c9d6f..9680f35 100644 --- a/src/WxAgent.Windows/WechatChatClient.Management.cs +++ b/src/WxAgent.Windows/WechatChatClient.Management.cs @@ -622,8 +622,10 @@ public static partial class WechatChatClient var unchanged = 0; var stopped = false; var scrolls = 0; - while (messages.Count < count && unchanged < 3 && !stopped) + while (messages.Count < count && unchanged < 3 && !stopped && scrolls < 100) { + cancellationToken.ThrowIfCancellationRequested(); + RequireNavigationSession(main, session); var before = messages.Count; foreach (var message in ReadVisible(main)) { @@ -638,17 +640,14 @@ public static partial class WechatChatClient } if (messages.Count >= count || stopped) break; unchanged = messages.Count == before ? unchanged + 1 : 0; - var list = FindByAutomationId(main, WechatLocators.MessageList) - ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Message list was not found."); - MoveToCenter(list); - Mouse.Scroll(5); + ScrollNavigationList(main, WechatLocators.MessageList, WechatScrollDirection.Up); scrolls++; await Task.Delay(200, cancellationToken).ConfigureAwait(false); } if (returnToLatest && scrolls > 0) { - Mouse.Scroll(-5d * scrolls); + await GoToLatestCoreAsync(main, session, 100, cancellationToken).ConfigureAwait(false); } return (IReadOnlyList<ChatMessageSnapshot>)messages.Take(count).ToArray(); }, cancellationToken); diff --git a/src/WxAgent.Windows/WechatChatClient.Navigation.cs b/src/WxAgent.Windows/WechatChatClient.Navigation.cs new file mode 100644 index 0000000..8958895 --- /dev/null +++ b/src/WxAgent.Windows/WechatChatClient.Navigation.cs @@ -0,0 +1,186 @@ +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Input; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public static partial class WechatChatClient +{ + public static Task<WechatSessionScrollResult> ScrollSessionsAsync(WechatScrollDirection direction, int pages = 1, CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, _) => + { + WechatNavigationRules.ValidateScrollLimit(pages); + if (!Enum.IsDefined(direction)) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown scroll direction."); + var before = ReadSessionViewport(main); + var initial = SessionViewportKey(before); + var scrolls = 0; + for (; scrolls < pages;) + { + cancellationToken.ThrowIfCancellationRequested(); + ScrollNavigationList(main, WechatLocators.SessionList, direction); + scrolls++; + await Task.Delay(350, cancellationToken).ConfigureAwait(false); + var after = ReadSessionViewport(main); + if (SessionViewportKey(before) == SessionViewportKey(after)) break; + before = after; + } + var sessions = ReadSessionViewport(main); + return new WechatSessionScrollResult(scrolls, initial != SessionViewportKey(sessions), sessions); + }, cancellationToken); + + public static Task<IReadOnlyList<ChatMessageSnapshot>> GoToLatestAsync(string session, int maxScrolls = 100, CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, _) => + { + ArgumentException.ThrowIfNullOrWhiteSpace(session); + WechatNavigationRules.ValidateScrollLimit(maxScrolls); + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + await GoToLatestCoreAsync(main, session, maxScrolls, cancellationToken).ConfigureAwait(false); + return ReadVisible(main); + }, cancellationToken); + + public static Task<WechatMessagePosition> LocateMessageAsync(string session, string fingerprint, int maxScrolls = 30, CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, _) => + { + ArgumentException.ThrowIfNullOrWhiteSpace(session); + WechatNavigationRules.ValidateScrollLimit(maxScrolls); + WechatNavigationRules.FindMessage([], fingerprint); + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + var previous = ""; + var unchanged = 0; + for (var page = 0; page <= maxScrolls; page++) + { + cancellationToken.ThrowIfCancellationRequested(); + RequireNavigationSession(main, session); + var visible = ReadVisible(main); + var match = WechatNavigationRules.FindMessage(visible, fingerprint); + if (match is not null) + { + var element = FindVisibleMessageElement(main, match.Fingerprint); + if (element.Patterns.ScrollItem.IsSupported) + ExecuteInputStep("locate-message", () => element.Patterns.ScrollItem.Pattern.ScrollIntoView()); + element = FindVisibleMessageElement(main, match.Fingerprint); + var list = FindByAutomationId(main, WechatLocators.MessageList)!; + if (element.Properties.IsOffscreen.ValueOrDefault || !element.BoundingRectangle.IntersectsWith(list.BoundingRectangle)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The matched message could not be brought into the visible viewport."); + RequireNavigationSession(main, session); + return new WechatMessagePosition(match, page); + } + var signature = string.Join('|', visible.Select(message => message.Fingerprint)); + unchanged = signature == previous ? unchanged + 1 : 0; + if (unchanged >= 2) + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The message was not found before the history viewport stopped changing."); + previous = signature; + if (page == maxScrolls) break; + ScrollNavigationList(main, WechatLocators.MessageList, WechatScrollDirection.Up); + await Task.Delay(350, cancellationToken).ConfigureAwait(false); + } + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The history search reached its scroll limit; absence was not established."); + }, cancellationToken); + + private static async Task GoToLatestCoreAsync(AutomationElement main, string session, int maxScrolls, CancellationToken cancellationToken) + { + var previous = ""; + var unchanged = 0; + for (var page = 0; page <= maxScrolls; page++) + { + cancellationToken.ThrowIfCancellationRequested(); + RequireNavigationSession(main, session); + var list = FindByAutomationId(main, WechatLocators.MessageList) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Message list was not found."); + if (list.Patterns.Scroll.IsSupported) + { + var pattern = list.Patterns.Scroll.Pattern; + ExecuteInputStep("latest-messages", () => pattern.SetScrollPercent(-1, 100)); + await Task.Delay(350, cancellationToken).ConfigureAwait(false); + RequireNavigationSession(main, session); + var fresh = FindByAutomationId(main, WechatLocators.MessageList)!; + if (fresh.Patterns.Scroll.IsSupported && fresh.Patterns.Scroll.Pattern.VerticalScrollPercent.Value >= 99.9) return; + } + var signature = string.Join('|', ReadVisible(main).Select(message => message.Fingerprint)); + unchanged = signature == previous ? unchanged + 1 : 0; + if (unchanged >= 2) return; // Bounded stable-viewport fallback; not a database history completeness claim. + previous = signature; + if (page == maxScrolls) break; + ScrollNavigationList(main, WechatLocators.MessageList, WechatScrollDirection.Down); + await Task.Delay(350, cancellationToken).ConfigureAwait(false); + } + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The latest-message navigation reached its scroll limit."); + } + + public static Task<long> OpenMergedChatAsync(string session, string fingerprint, CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, automation) => + { + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + var window = await OpenMergedChatWindowCoreAsync(main, automation, session, fingerprint, cancellationToken).ConfigureAwait(false); + return window.Properties.NativeWindowHandle.ValueOrDefault.ToInt64(); + }, cancellationToken); + + private static async Task<AutomationElement> OpenMergedChatWindowCoreAsync(AutomationElement main, FlaUI.UIA3.UIA3Automation automation, + string session, string fingerprint, CancellationToken cancellationToken) + { + RequireNavigationSession(main, session); + var message = WechatNavigationRules.FindMessage(ReadVisible(main), fingerprint) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The merged-chat message is not currently loaded; locate it first."); + if (message.Type != ChatMessageType.Merge) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The selected message is not a merged-chat record."); + var before = automation.GetDesktop().FindAllChildren() + .Select(element => element.Properties.NativeWindowHandle.ValueOrDefault).ToHashSet(); + var bubble = FindVisibleMessageElement(main, message.Fingerprint); + ExecuteInputStep("open-merged-chat", () => bubble.DoubleClick()); + var started = System.Diagnostics.Stopwatch.StartNew(); + while (started.Elapsed < TimeSpan.FromSeconds(8)) + { + cancellationToken.ThrowIfCancellationRequested(); + var windows = automation.GetDesktop().FindAllChildren() + .Where(element => element.Properties.ProcessId.ValueOrDefault == main.Properties.ProcessId.ValueOrDefault + && !before.Contains(element.Properties.NativeWindowHandle.ValueOrDefault) + && !element.Properties.IsOffscreen.ValueOrDefault && !element.BoundingRectangle.IsEmpty + && SafeName(element).Contains("聊天记录", StringComparison.Ordinal)).ToArray(); + if (windows.Length > 1) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Multiple new merged-chat windows appeared."); + if (windows.Length == 1) return windows[0]; + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + } + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "No unique new merged-chat window was confirmed."); + } + + private static void RequireNavigationSession(AutomationElement main, string session) + { + if (!IsListeningSession(main, session, independent: false)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed during navigation."); + } + + private static void ScrollNavigationList(AutomationElement main, string automationId, WechatScrollDirection direction) + { + var list = FindByAutomationId(main, automationId) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The navigation list was not found."); + ExecuteInputStep("scroll-navigation-list", () => + { + if (list.Patterns.Scroll.IsSupported) + { + var pattern = list.Patterns.Scroll.Pattern; + if (!pattern.VerticallyScrollable.Value) return; + var step = Math.Max(1, pattern.VerticalViewSize.Value * 0.8); + var percent = Math.Clamp(pattern.VerticalScrollPercent.Value + (direction == WechatScrollDirection.Up ? -step : step), 0, 100); + pattern.SetScrollPercent(-1, percent); + return; + } + MoveToCenter(list); + Mouse.Scroll(direction == WechatScrollDirection.Up ? 5 : -5); + }); + } + + private static IReadOnlyList<WechatSessionSnapshot> ReadSessionViewport(AutomationElement main) + { + var list = FindByAutomationId(main, WechatLocators.SessionList) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Session list was not found."); + var current = SafeName(FindByAutomationId(main, WechatLocators.CurrentChatName)); + return WechatSessionParser.Parse(list.FindAllDescendants() + .Where(element => !element.Properties.IsOffscreen.ValueOrDefault && element.BoundingRectangle.IntersectsWith(list.BoundingRectangle)) + .Select(element => (SafeAutomationId(element), SafeName(element))), current); + } + + private static string SessionViewportKey(IReadOnlyList<WechatSessionSnapshot> sessions) => + string.Join('|', sessions.Select(session => session.AutomationId)); +} diff --git a/src/WxAgent.Windows/WechatChatClient.cs b/src/WxAgent.Windows/WechatChatClient.cs index 4ce29d2..6919520 100644 --- a/src/WxAgent.Windows/WechatChatClient.cs +++ b/src/WxAgent.Windows/WechatChatClient.cs @@ -303,8 +303,10 @@ public static partial class WechatChatClient public static async Task<IReadOnlyList<ChatMessageSnapshot>> ReadHistoryAsync( int maxMessages, int maxScrolls, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string session = WechatLocators.FileTransferAssistant) { + ArgumentException.ThrowIfNullOrWhiteSpace(session); if (maxMessages is < 1 or > 1000 || maxScrolls is < 0 or > 100) { throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "History limits are maxMessages 1-1000 and maxScrolls 0-100."); @@ -315,7 +317,7 @@ public static partial class WechatChatClient { using var automation = new UIA3Automation(); var window = AttachWindow(automation, cancellationToken); - await OpenFileTransferAssistantCoreAsync(window, cancellationToken); + await OpenNamedSessionAsync(window, session, cancellationToken).ConfigureAwait(false); var messages = new List<ChatMessageSnapshot>(); var deduper = new BoundedMessageDeduper(Math.Max(2048, maxMessages * 4)); var unchanged = 0; @@ -323,24 +325,21 @@ public static partial class WechatChatClient for (var page = 0; page <= maxScrolls; page++) { cancellationToken.ThrowIfCancellationRequested(); - window = WechatDoctor.FindWechatWindow(automation) ?? window; + RequireNavigationSession(window, session); var visible = ReadVisible(window); var older = visible.Where(message => deduper.TryAdd(message.Fingerprint)).ToArray(); if (older.Length == 0) unchanged++; else unchanged = 0; if (page == 0) messages.AddRange(older); else messages.InsertRange(0, older); if (messages.Count >= maxMessages || unchanged >= 2 || page == maxScrolls) break; - var list = FindByAutomationId(window, WechatLocators.MessageList) - ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found."); - MoveToCenter(list); - Mouse.Scroll(5); + ScrollNavigationList(window, WechatLocators.MessageList, WechatScrollDirection.Up); scrolled++; await Task.Delay(350, cancellationToken); } if (scrolled > 0) { - Mouse.Scroll(-5d * scrolled); + await GoToLatestCoreAsync(window, session, 100, cancellationToken).ConfigureAwait(false); } return messages.Count <= maxMessages ? messages : messages.TakeLast(maxMessages).ToArray(); diff --git a/tests/WxAgent.Core.Tests/WechatNavigationTests.cs b/tests/WxAgent.Core.Tests/WechatNavigationTests.cs new file mode 100644 index 0000000..b16d4ff --- /dev/null +++ b/tests/WxAgent.Core.Tests/WechatNavigationTests.cs @@ -0,0 +1,23 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatNavigationTests +{ + [Theory] + [InlineData(0)] + [InlineData(101)] + public void RejectsUnboundedScrollRequests(int pages) => + Assert.Throws<WxAgentException>(() => WechatNavigationRules.ValidateScrollLimit(pages)); + + [Fact] + public void MatchesExactVisibleFingerprintAndRejectsAmbiguity() + { + var message = new ChatMessageSnapshot("test", new string('a', 64), 0, ChatMessageType.Text); + Assert.Same(message, WechatNavigationRules.FindMessage([message], new string('A', 64))); + Assert.Null(WechatNavigationRules.FindMessage([message], new string('b', 64))); + Assert.Throws<WxAgentException>(() => WechatNavigationRules.FindMessage([message, message], message.Fingerprint)); + Assert.Throws<WxAgentException>(() => WechatNavigationRules.FindMessage([message], "not-a-fingerprint")); + } +}