feat: add bounded session and history navigation

Add session scrolling, latest-message navigation and fingerprint location under the shared UI gate; reuse navigation in history reads and provide read-only smoke. Navigation index snapshot passed 124 tests and a full build. Windows 4.1.13.63 read-only validation passed 10/10, including two-scroll history location; no sends. Record scope limits, including next-unread navigation not yet implemented.
This commit is contained in:
2026-09-06 20:09:00 +08:00
parent c49f6a10b2
commit e5548bd471
13 changed files with 563 additions and 24 deletions
+29
View File
@@ -0,0 +1,29 @@
# 导航增量验收 — 2026-09-06
## 本次实现
- `session scroll --direction up|down --pages N`:会话列表滚动;默认不输出联系人名称,`--include-content` 显式开启。
- `chat latest [--session <name>]`:回到指定会话的最新可见消息。
- `chat locate --fingerprint <可见消息指纹> [--session <name>] [--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`
- 本增量未实现“下一个未读会话”。会话列表缺少已验证的未读标记时,不从预览文本猜测未读状态。
@@ -0,0 +1,10 @@
{
"Scrolls": 2,
"message": {
"Fingerprint": "1e59c51f8086b6ec6d80e34a16daabc0359f0fd4f4633289113e653b9b0ab166",
"Type": "Text",
"length": 61,
"content": null,
"quote": null
}
}
@@ -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
}
]
}
@@ -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
}
]
}
@@ -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
}
@@ -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
}
+40
View File
@@ -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}
+27
View File
@@ -0,0 +1,27 @@
namespace WxAgent.Core;
public enum WechatScrollDirection { Up, Down }
public sealed record WechatSessionScrollResult(
int Scrolls, bool ViewportChanged, IReadOnlyList<WechatSessionSnapshot> 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<ChatMessageSnapshot> 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();
}
}
+66 -10
View File
@@ -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 <dir> [--baseline <ui-tree.json>] [--timeout 60]
inspect-ui --output <path> [--window-title <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]
@@ -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);
@@ -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));
}
+7 -8
View File
@@ -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();
@@ -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"));
}
}