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:
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user