diff --git a/control-plane/send_text_payload_test.go b/control-plane/send_text_payload_test.go new file mode 100644 index 0000000..e5749e5 --- /dev/null +++ b/control-plane/send_text_payload_test.go @@ -0,0 +1,39 @@ +package controlplane + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestValidSendTextPayloadSupportsBoundedLongText(t *testing.T) { + cases := []struct { + name string + text string + confirmed bool + want bool + }{ + {name: "single message", text: "hello", confirmed: true, want: true}, + {name: "long text", text: strings.Repeat("中", 20_000), confirmed: true, want: true}, + {name: "over limit", text: strings.Repeat("中", 20_001), confirmed: true, want: false}, + {name: "unpaired input", text: "hello\x00world", confirmed: true, want: false}, + {name: "blank", text: " \n\t", confirmed: true, want: false}, + {name: "not confirmed", text: "hello", confirmed: false, want: false}, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + payload, err := json.Marshal(struct { + TargetID string `json:"target_id"` + Text string `json:"text"` + Confirmed bool `json:"confirmed"` + }{TargetID: "test-session", Text: test.text, Confirmed: test.confirmed}) + if err != nil { + t.Fatal(err) + } + if got := validSendTextPayload(json.RawMessage(payload)); got != test.want { + t.Fatalf("validSendTextPayload() = %v, want %v", got, test.want) + } + }) + } +} diff --git a/control-plane/server.go b/control-plane/server.go index 055b03a..55fcaf8 100644 --- a/control-plane/server.go +++ b/control-plane/server.go @@ -22,6 +22,8 @@ import ( "strings" "sync" "time" + "unicode" + "unicode/utf16" webassets "git.ipao.vip/rogee/wx-win-agent/control-plane/web" ) @@ -1634,7 +1636,15 @@ func validSendTextPayload(payload jsonRaw) bool { if json.Unmarshal(payload, &value) != nil { return false } - return validIdentifier(value.TargetID, 512) && len(value.Text) > 0 && len(value.Text) <= 4000 && value.Confirmed + if !validIdentifier(value.TargetID, 512) || strings.TrimSpace(value.Text) == "" || len(utf16.Encode([]rune(value.Text))) > 20_000 || !value.Confirmed { + return false + } + for _, character := range value.Text { + if unicode.IsControl(character) && character != '\n' && character != '\r' && character != '\t' && character != '\u0085' { + return false + } + } + return true } func eventHash(event MessageEvent) string { diff --git a/docs/PENDING.md b/docs/PENDING.md index 6deb343..141a8ec 100644 --- a/docs/PENDING.md +++ b/docs/PENDING.md @@ -2,6 +2,14 @@ 本文件记录全局 review 后的用户决定。用户随后确认同时完善数据库读取链路和四项代码问题;以下明确暂缓的扩展与真机验收仍不执行。暂缓不等于通过,安全修复也不等于端到端功能已验收。 +## 当前正常功能复核(2026-09-29) + +- 长文本分段、2–10 个附件批量入口、提及重名 fail-closed 保护和合并聊天混合元数据解析已补代码及离线测试;TaskNotFound 修复保留。 +- 已通过 Core/Service .NET 测试、Go 测试与 vet、WebUI 测试/构建及完整 Release solution build。Windows 新版本已部署,`service.json` 未变。 +- Windows `inspect-ui` 成功,但 `doctor`/`smoke` 返回 `WechatNotLoggedIn`,必需聊天控件缺失;本轮未发送任何消息/附件,也未执行任何 M4/M5 可见写操作。上述功能的真机验收保持未完成,不得勾选为通过。 +- 可脱离聊天 UI 的只读检查通过:批准的测试群成员页返回 2 个成员、0 空显示名、0 重名且无续页;`chat send-files` 重复 `--path` 解析成功,并在随机缺失路径预校验返回 `InvalidArgument`,未触及 UI。只保留聚合计数,未记录成员姓名。 +- 详细范围、哈希、证据及未闭环项见[正常功能复核与验收记录](validation/Normal-Functional-Acceptance-2026-09-29.md)。M6 仍保持未完成。 + ## 已确定的功能边界 - **联系人、群成员等列表读取使用数据库,不使用 UI 树枚举。** 撤回上一轮将 UI 树长列表枚举不完整作为待补功能的建议,不新增 UI 滚动分页。此项记录用户指定的读取方式,不代表本轮已逐条验证数据库接口覆盖情况。 @@ -51,7 +59,7 @@ M6 保持未完成状态,不因暂缓而改成验收通过。恢复写操作 - [ ] **独立 OCR 窗口及各版本转文字结构**:当前仅支持目标消息内新增文本。独立弹窗/侧栏没有可靠的结果归属定位时返回 `ResultUnconfirmed`;状态关键词属于保守启发式,可能拒绝包含相同文字的合法结果。 - [x] **写操作结果确认**:统一等待确认弹窗关闭且 UI 连续稳定后返回 `Success=true`;下载和笔记附件保存另有文件系统后置检查。微信服务端最终一致性仍需真机长测记录。 - [ ] **管理弹窗真机定位**:确认各版本模态窗口、选人复选框及保存对话框结构;暂不支持的内嵌/非模态布局会明确失败,不全窗乱点。 -- [ ] **联系人数据库字段覆盖**:当前只映射已知 username/nick_name/remark/small_head_url。非群联系人不等于已验证好友关系;标签、签名、来源和共同群数量保持未知。`GetRecentGroupsAsync` 兼容名称目前返回数据库已知群,不提供最近活动排序。 +- [ ] **联系人数据库字段覆盖(阻塞)**:当前只映射已验证的 username/nick_name/remark/small_head_url。2026-09-29 Windows 只读 schema 检查确认 `contact` 有 22 列(另见 `alias`、`description`、`extra_buffer`),并有 `contact_label` 定义表;没有验证出联系人到标签的关联或这些字段的业务语义,故不猜测映射、不解析不透明 BLOB。非群联系人也不等于已验证好友关系;标签、签名、来源和共同群数量仍未知。`GetRecentGroupsAsync` 是参考实现的兼容名称,当前 C# 计划没有该 API,且不要求 Python API 兼容;近期活动排序没有已验收的数据源,不作为本轮承诺。 - [ ] **分页一致性**:跨页为独立只读查询;读取期间联系人增删可能导致 offset 分页重复/遗漏。需要严格全量快照时应增加一致性读取,不将跨页结果宣传为事务快照。 实现和 Linux 检查详情见 [本轮检查记录](validation/Review-Database-Management-Safety.md)。 diff --git a/docs/validation/Normal-Functional-Acceptance-2026-09-29.md b/docs/validation/Normal-Functional-Acceptance-2026-09-29.md new file mode 100644 index 0000000..5ebb17b --- /dev/null +++ b/docs/validation/Normal-Functional-Acceptance-2026-09-29.md @@ -0,0 +1,52 @@ +# Normal 功能复核与验收记录 — 2026-09-29 + +## 范围与非目标 + +本轮按 `docs/PENDING.md`、开发计划及 M4–M6 验收记录复核;不把短时 smoke 当成长期稳定性证据。 + +- 纳入正常功能:保留 TaskNotFound 结果报告修复;长文本分段;同一调用发送多个附件;提及成员的重名保护;合并聊天混合附件/嵌套元数据解析;只读联系人/群数据边界。 +- 本轮明确不做:压力或批量消息发送、连续 500 条发送、8/24/72 小时长稳、20 次重启/网络恢复压力验证、Win10 DPI 矩阵及不可用的 Windows 11/麦克风验证。 +- 好友增删、群成员变更、朋友圈发布/点赞/评论等写操作仍需单独授权,本轮未执行。没有进行登录或风控绕过、协议/CDN/AES 破解、DLL 注入、数据库写入,也没有实现 Windows Service、授权或自动升级。 + +## 代码与自动化检查 + +- Core:188/188 测试通过。覆盖长文本 4,000 字符分段、组合字符边界、20,000 字符总上限、附件路径预校验、同名提及候选拒绝、重复 fingerprint 的新增出现计数,以及合并聊天图片/视频/文件/嵌套元数据。 +- Service:26/26 测试通过。新增验证长 `send-text` 可进入分段发送路径,同时 `broadcast-text` 仍保持单条消息上限。 +- Control plane:`gofmt`、`go test ./...`、`go vet ./...` 通过。发送文本按最多 20,000 UTF-16 code units 校验,并拒绝无效控制字符。 +- WebUI:7/7 测试通过;`npm run build` 成功。Vite 对大 bundle 的提示不是构建失败。 +- 完整 Release solution build(含 Windows targeting):0 warnings、0 errors。 +- 未对多附件发送执行 UI/真机调用;无压力或批量消息发送。 + +## Windows 部署与验收结果 + +- 部署位置:`C:\Users\Rogee\wx-agent01`。Host SHA-256:`942CDE7F424D4A7158904F79F1D172330E769419E0AF8AB3BDB910C383031AB0`;Tray SHA-256:`C60A82C69F009A6C8A3C1ADFABE7D3D0C87CA88BDE2CCFEC514DCFC594C1D998`。 +- `service.json` SHA-256 部署前后均为 `5F4F40E040A7D69060CC09F1A390277286FA982D6EFBEF3D825DBCF29B6A56BB`,文件未修改。回滚备份:`C:\Users\Rogee\wx-agent01\backup-functional-20260929-163843`。 +- Tray 已在交互式 Session 1 重启。通过 Session 1 临时验证任务运行了项目要求的 `doctor`、`inspect-ui`、`smoke`:`inspect-ui` exit 0;`doctor` 和 `smoke` exit 2,错误码 `WechatNotLoggedIn`。`UserInteractive=true`、`InputDesktopAvailable=true`、`WindowFound=true`,但 `MainView`、`session_list`、`chat_message_page`、`chat_message_list`、`chat_input_field`、`tool_bar_accessible` 均未找到。 +- 因缺少可用的微信聊天控件,本轮没有发送消息、文件或提及成员,也没有继续对登录界面进行交互。UI 树仅保存于 Windows 主机的 `artifacts/ui-tree.json`,未读取或附入本记录。 +- 另对批准的“消息测试专用群组”运行 Windows Host 只读 `db group-members` 查询:2 个成员、2 个非空显示名、0 个重复显示名、`hasMore=false`。脚本只输出并保存聚合计数,没有记录成员姓名或账号标识。 +- Windows Host CLI `chat send-files` 用两条随机不存在的路径做无发送预校验:重复 `--path` 解析成功,按预期返回 `InvalidArgument`(附件文件不存在);此分支在进入 UI 自动化前拒绝输入,没有发送文件。 +- Windows 只读 schema 探针发现 `contact` 表有 22 列,`contact_label` 有 `label_id_`/`label_name_`/`sort_order_` 三列;未确认联系人与标签的关联。`alias`、`description`、`extra_buffer` 的业务语义也未验证;探针仅读 schema,没有读取联系人行值,不映射字段、不解析不透明 BLOB。 + +## 项目待办状态(不得视为通过) + +| 项目 | 代码检查 | 真机/样本验收 | +|---|---|---| +| 长文本分段与部分发送错误码 | 已实现并有 Core/Service/Go 测试 | **Blocked**:聊天输入控件缺失;未发送测试文本 | +| 2–10 个附件的单次命令及全路径预校验 | 已实现并有 Core 测试;Windows CLI 重复 `--path` 预校验通过;每个附件逐一确认,失败不盲目重试 | **Blocked**:UI 不可用;且本轮禁止批量/压力发送,未发送附件 | +| 群成员只读分页 | Windows Host 在批准的测试群读出 2 个成员,无空/重复显示名且无续页;只记录聚合数 | **通过**:只读 DB 查询;不代表 mention UI 已验收 | +| 提及成员长列表/重名候选 | 数据库显示名唯一性和可见选项唯一性均采用 fail-closed 逻辑,有 Core 测试 | **Blocked**:当前没有 mention/chat 控件;长列表搜索行为未真机验证,不做 UI 滚动分页 | +| 合并聊天混合与嵌套内容 | 增加离线混合元数据测试 | **Blocked**:没有对应的受限测试样本;历史 16 条纯文本样本不等于混合/嵌套真机验收 | +| 合并聊天附件原文件下载 | 未实现 | **Blocked / 未实现**:不能以解析元数据冒充原文件下载;不通过协议/CDN/AES 路径实现 | +| 独立 OCR 窗口、版本差异转文字 | 保留无法归属时 `ResultUnconfirmed` 的保守行为 | **Blocked**:本轮无可用 UI 与样本,不把结果归属不明的文本当作成功 | +| 管理弹窗、会话切换、多窗口归属/去重 | 未做真机回归 | **Blocked**:缺少聊天控件;涉及写操作的验证另需授权 | +| 联系人详情额外字段(WechatId、标签、签名、来源、共同群) | Windows schema 已检查;`GetFriendDetailsAsync` 无仓库内调用/CLI/REST 暴露;没有验证字段语义或标签关联 | **Blocked**:不猜 `alias`/`description`/`extra_buffer` 语义,不解析未知 BLOB;见 `docs/PENDING.md` | +| 最近群活动排序 | 当前 C# 计划/源代码没有 `GetRecentGroupsAsync` API;不要求 wxautox4 Python API 兼容 | **Out of scope**:现有群目录不承诺按活动排序;若需排序须先确认 C# 产品契约和已验证活动源 | +| 跨页事务快照 | 页内有稳定排序及 `hasMore`;跨页是独立只读查询 | **Known limitation**:不承诺事务快照;本轮不做并发变更/压力测试,且未将结果描述为快照 | +| 语音发送 Beta | 未验收 | **Blocked**:环境无麦克风 | +| M4/M5 可见写操作及 M6 长稳/兼容矩阵 | 未执行 | **Blocked 或明确非目标**:见范围与非目标;M6 保持未完成 | + +先前经授权完成的消息同步任务(sequence 783、100 conversations)及 TaskNotFound 修复回归证据保留在既有记录中;本次没有恢复其他等待任务。 + +## 后续所需输入 + +在 Windows 主机的已登录、未锁定交互式 Session 1 中由用户手动打开并登录微信后,再通知可进行受限功能验收。不得由 Agent 输入凭据或绕过登录。M4/M5 写操作仍需各自单独授权;附件原文件下载若需协议/CDN/AES 破解仍不实施。 diff --git a/node-agent/WxAgent.Core/RemoteTaskLedger.cs b/node-agent/WxAgent.Core/RemoteTaskLedger.cs index cf40071..14e1323 100644 --- a/node-agent/WxAgent.Core/RemoteTaskLedger.cs +++ b/node-agent/WxAgent.Core/RemoteTaskLedger.cs @@ -31,6 +31,9 @@ public sealed record LocalRemoteTaskRecord [JsonPropertyName("reported")] public bool Reported { get; init; } + [JsonPropertyName("resultReportErrorCode")] + public string? ResultReportErrorCode { get; init; } + [JsonPropertyName("updatedAt")] public DateTimeOffset UpdatedAt { get; init; } } @@ -136,6 +139,7 @@ public sealed class RemoteTaskLedger Result = scopes.Length == 0 ? result with { Content = null } : result, ReportingScopes = scopes, Reported = false, + ResultReportErrorCode = null, UpdatedAt = DateTimeOffset.UtcNow }; SaveLocked(); @@ -147,7 +151,7 @@ public sealed class RemoteTaskLedger lock (_gate) { return _records.Values - .Where(record => !record.Reported && record.Result is not null) + .Where(record => !record.Reported && record.Result is not null && record.ResultReportErrorCode is null) .Select(record => record.Result!) .ToArray(); } @@ -158,19 +162,37 @@ public sealed class RemoteTaskLedger lock (_gate) { return _records.Values - .Where(record => !record.Reported && record.Result is not null) + .Where(record => !record.Reported && record.Result is not null && record.ResultReportErrorCode is null) .Select(record => new UnreportedRemoteTask(record.Result!, record.ReportingScopes)) .ToArray(); } } + public int UnreportableResultCount() + { + lock (_gate) + return _records.Values.Count(record => !record.Reported && record.Result is not null && record.ResultReportErrorCode is not null); + } + public void MarkReported(string taskId) { lock (_gate) { if (_records.TryGetValue(taskId, out var existing) && existing.Result is not null) { - _records[taskId] = existing with { Reported = true, UpdatedAt = DateTimeOffset.UtcNow }; + _records[taskId] = existing with { Reported = true, ResultReportErrorCode = null, UpdatedAt = DateTimeOffset.UtcNow }; + SaveLocked(); + } + } + } + + public void MarkResultTaskNotFound(string taskId) + { + lock (_gate) + { + if (_records.TryGetValue(taskId, out var existing) && existing.Result is not null && !existing.Reported) + { + _records[taskId] = existing with { ResultReportErrorCode = "TaskNotFound", UpdatedAt = DateTimeOffset.UtcNow }; SaveLocked(); } } @@ -190,6 +212,7 @@ public sealed class RemoteTaskLedger Result = new RemoteTaskResult(pair.Value.TaskId, pair.Value.AccountId, pair.Value.LeaseGeneration, RemoteTaskStatus.ResultUnconfirmed, "AgentRestartedWithIncompleteTask", "Execution was not replayed.", true, null, Guid.NewGuid().ToString("N")), Reported = false, + ResultReportErrorCode = null, UpdatedAt = DateTimeOffset.UtcNow }; changed = true; diff --git a/node-agent/WxAgent.Core/WechatAttachmentInput.cs b/node-agent/WxAgent.Core/WechatAttachmentInput.cs new file mode 100644 index 0000000..a4efb81 --- /dev/null +++ b/node-agent/WxAgent.Core/WechatAttachmentInput.cs @@ -0,0 +1,37 @@ +namespace WxAgent.Core; + +public static class WechatAttachmentInput +{ + public const int MaxBatchSize = 10; + private static readonly HashSet ImageExtensions = new([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"], StringComparer.OrdinalIgnoreCase); + + public static bool IsImagePath(string? path) => + !string.IsNullOrWhiteSpace(path) && ImageExtensions.Contains(Path.GetExtension(path)); + + public static IReadOnlyList PrepareFilePaths(IReadOnlyList? paths) + { + if (paths is null || paths.Count is < 2 or > MaxBatchSize) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"File batches must contain 2-{MaxBatchSize} attachments."); + + var normalized = new string[paths.Count]; + for (var index = 0; index < paths.Count; index++) + { + var path = paths[index]; + if (string.IsNullOrWhiteSpace(path)) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Attachment paths must not be empty."); + + string fullPath; + try { fullPath = Path.GetFullPath(path); } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "An attachment path is invalid."); + } + + if (!File.Exists(fullPath)) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "An attachment file does not exist."); + normalized[index] = fullPath; + } + + return normalized; + } +} diff --git a/node-agent/WxAgent.Core/WechatMentionOptionSelection.cs b/node-agent/WxAgent.Core/WechatMentionOptionSelection.cs new file mode 100644 index 0000000..4ab0739 --- /dev/null +++ b/node-agent/WxAgent.Core/WechatMentionOptionSelection.cs @@ -0,0 +1,25 @@ +namespace WxAgent.Core; + +public static class WechatMentionOptionSelection +{ + public static int FindUniqueVisibleOptionIndex( + IReadOnlyList groupMemberDisplayNames, + IReadOnlyList<(string? Name, bool IsOffscreen, bool HasBounds)> options, + string requestedName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(requestedName); + if (groupMemberDisplayNames.Count(name => string.Equals(name, requestedName, StringComparison.Ordinal)) != 1) + return -1; + + var match = -1; + for (var index = 0; index < options.Count; index++) + { + var option = options[index]; + if (!string.Equals(option.Name, requestedName, StringComparison.Ordinal)) continue; + if (match >= 0 || option.IsOffscreen || !option.HasBounds) return -1; + match = index; + } + + return match; + } +} diff --git a/node-agent/WxAgent.Core/WechatSendConfirmation.cs b/node-agent/WxAgent.Core/WechatSendConfirmation.cs index ee350c1..55e9be9 100644 --- a/node-agent/WxAgent.Core/WechatSendConfirmation.cs +++ b/node-agent/WxAgent.Core/WechatSendConfirmation.cs @@ -4,10 +4,16 @@ public static class WechatSendConfirmation { public static bool IsMentionToken(string text) => text.TrimEnd() == "\uFFFC"; - public static bool MatchesMention(ChatMessageSnapshot message, IReadOnlySet baseline, + public static bool IsNewOccurrence(string fingerprint, + IReadOnlyDictionary baselineCounts, IReadOnlyDictionary currentCounts) => + currentCounts.TryGetValue(fingerprint, out var currentCount) + && currentCount > (baselineCounts.TryGetValue(fingerprint, out var baselineCount) ? baselineCount : 0); + + public static bool MatchesMention(ChatMessageSnapshot message, + IReadOnlyDictionary baselineCounts, IReadOnlyDictionary currentCounts, string memberName, string? body) { - if (baseline.Contains(message.Fingerprint) || message.Type != ChatMessageType.Text) return false; + if (!IsNewOccurrence(message.Fingerprint, baselineCounts, currentCounts) || message.Type != ChatMessageType.Text) return false; var expected = "@" + memberName + (string.IsNullOrEmpty(body) ? "" : " " + body); return string.Equals(NormalizeWhitespace(message.Text), NormalizeWhitespace(expected), StringComparison.Ordinal); } @@ -16,10 +22,11 @@ public static class WechatSendConfirmation string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); // UIA proves only a fresh matching visible row, not delivery or sender identity. - public static bool Matches(ChatMessageSnapshot message, IReadOnlySet baseline, + public static bool Matches(ChatMessageSnapshot message, + IReadOnlyDictionary baselineCounts, IReadOnlyDictionary currentCounts, ChatMessageType type, string? expectedText) { - if (baseline.Contains(message.Fingerprint) || message.Type != type) return false; + if (!IsNewOccurrence(message.Fingerprint, baselineCounts, currentCounts) || message.Type != type) return false; if (expectedText is null) return true; return type == ChatMessageType.File ? message.Text.Split('\n').Any(line => string.Equals(line.TrimEnd('\r'), expectedText, StringComparison.Ordinal)) diff --git a/node-agent/WxAgent.Core/WechatTextInput.cs b/node-agent/WxAgent.Core/WechatTextInput.cs index b91cc4e..0d07560 100644 --- a/node-agent/WxAgent.Core/WechatTextInput.cs +++ b/node-agent/WxAgent.Core/WechatTextInput.cs @@ -1,14 +1,54 @@ +using System.Globalization; + namespace WxAgent.Core; public static class WechatTextInput { + public const int MaxSegmentLength = 4000; + public const int MaxTextLength = 20_000; + public static string Prepare(string text) + { + var normalized = PrepareLongText(text); + if (normalized.Length > MaxSegmentLength) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Message text must not exceed {MaxSegmentLength} characters."); + return normalized; + } + + public static string PrepareLongText(string text) { if (string.IsNullOrWhiteSpace(text)) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Message text must not be empty."); var normalized = text.ReplaceLineEndings("\n"); - if (normalized.Length > 4000 || normalized.Any(character => char.IsControl(character) && character is not ('\n' or '\t'))) - throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Message text must not exceed 4000 characters or contain unsupported control characters."); + if (normalized.Length > MaxTextLength || normalized.Any(character => char.IsControl(character) && character is not ('\n' or '\t'))) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Message text must not exceed {MaxTextLength} characters or contain unsupported control characters."); return normalized; } + + public static IReadOnlyList SplitForSend(string text) + { + var normalized = PrepareLongText(text); + if (normalized.Length <= MaxSegmentLength) + return [normalized]; + + var textElementStarts = StringInfo.ParseCombiningCharacters(normalized); + var segments = new List(); + var start = 0; + while (start < normalized.Length) + { + var end = Math.Min(start + MaxSegmentLength, normalized.Length); + if (end < normalized.Length) + { + var boundaryIndex = Array.BinarySearch(textElementStarts, end); + if (boundaryIndex < 0) boundaryIndex = ~boundaryIndex - 1; + if (boundaryIndex < 0 || textElementStarts[boundaryIndex] <= start) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "A text element exceeds the per-message length limit."); + end = textElementStarts[boundaryIndex]; + } + + segments.Add(normalized[start..end]); + start = end; + } + return segments; + } } diff --git a/node-agent/WxAgent.Host/Program.cs b/node-agent/WxAgent.Host/Program.cs index e9825c8..54f5a78 100644 --- a/node-agent/WxAgent.Host/Program.cs +++ b/node-agent/WxAgent.Host/Program.cs @@ -309,6 +309,14 @@ try WriteJson(result); return result.Success ? 0 : 2; } + case "chat" when args[1] == "send-files": + { + var paths = GetOptions(args, "--path"); + var session = GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant; + var messages = await WechatChatClient.SendFilesAsync(paths, cancellationToken, session); + WriteJson(new { requestedCount = messages.Count, confirmedCount = messages.Count }); + return 0; + } case "chat" when args[1] is "send-file" or "send-image": { var path = GetRequiredOption(args, "--path"); @@ -793,10 +801,16 @@ static int ValidateCommandLine(string[] values) return 60; case "send": ValidateOptions(values, 2, ["--text", "--session", "--timeout"], []); - return 30; + return 120; case "reply-latest": ValidateOptions(values, 2, ["--text", "--session", "--timeout"], []); return 60; + case "send-files": + ValidateOptions(values, 2, ["--path", "--session", "--timeout"], [], ["--path"]); + var pathCount = GetOptions(values, "--path").Count; + if (pathCount is < 2 or > WechatAttachmentInput.MaxBatchSize) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"send-files requires 2-{WechatAttachmentInput.MaxBatchSize} --path values."); + return checked(pathCount * 40 + 30); case "send-file": case "send-image": ValidateOptions(values, 2, ["--path", "--session", "--timeout"], []); @@ -888,13 +902,13 @@ static int ValidateCommandLine(string[] values) } } -static void ValidateOptions(string[] values, int startIndex, HashSet valueOptions, HashSet flagOptions) +static void ValidateOptions(string[] values, int startIndex, HashSet valueOptions, HashSet flagOptions, HashSet? repeatableValueOptions = null) { var seen = new HashSet(StringComparer.Ordinal); for (var index = startIndex; index < values.Length; index++) { var option = values[index]; - if (!seen.Add(option)) + if (!seen.Add(option) && repeatableValueOptions?.Contains(option) != true) { throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Duplicate option {option}."); } @@ -924,6 +938,19 @@ static string? GetOption(string[] values, string name) : null; } +static IReadOnlyList GetOptions(string[] values, string name) +{ + var options = new List(); + for (var index = 0; index + 1 < values.Length; index++) + { + if (string.Equals(values[index], name, StringComparison.Ordinal)) + { + options.Add(values[++index]); + } + } + return options; +} + static string GetRequiredOption(string[] values, string name) => GetOption(values, name) ?? throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Missing required option {name}."); @@ -993,8 +1020,7 @@ static int GetPositiveIntOption(string[] values, string name, int fallback, int return parsed; } -static bool IsImagePath(string path) => new[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp" } - .Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase); +static bool IsImagePath(string path) => WechatAttachmentInput.IsImagePath(path); static object ToEventOutput(MessageEvent messageEvent, bool includeContent) => new { @@ -1041,10 +1067,11 @@ WxAgent.Tray.exe must be started by double-click; use its 服务设置... window group verify-at-all --group --message [--timeout 30] chat send-url-card --to --url [--message ] [--timeout 60] chat send-audio --to --path --confirm CONFIRM [--timeout 60] - chat send --text [--session ] [--timeout 30] + chat send --text [--session ] [--timeout 120] chat reply-latest --text [--session ] [--timeout 60] chat send-file --path [--session ] [--timeout 60] chat send-image --path [--session ] [--timeout 60] +chat send-files --path --path ... [--session ] [--timeout ] (2-10; images sent as images) chat mention --session --member --text --confirm CONFIRM [--timeout 60] chat read [--limit 20] [--include-content] [--timeout 30] chat history [--session ] [--limit 100] [--scrolls 10] [--include-content] [--timeout 60] diff --git a/node-agent/WxAgent.Host/RemoteCliCommands.cs b/node-agent/WxAgent.Host/RemoteCliCommands.cs index aaf6f3c..ecb99fa 100644 --- a/node-agent/WxAgent.Host/RemoteCliCommands.cs +++ b/node-agent/WxAgent.Host/RemoteCliCommands.cs @@ -67,6 +67,7 @@ internal static class RemoteCliCommands configured = configuration.Remote.IsConfigured, reporting = new { configuration.Reporting.Enabled, configuration.Reporting.ConfigVersion, pendingEvents = eventQueue.PendingCount }, unreportedTaskResults = ledger.UnreportedResults().Count, + unreportableTaskResults = ledger.UnreportableResultCount(), audit = configuration.Audit.TakeLast(20) }; } diff --git a/node-agent/WxAgent.Service/AgentService.cs b/node-agent/WxAgent.Service/AgentService.cs index 541e1ca..3fd49c3 100644 --- a/node-agent/WxAgent.Service/AgentService.cs +++ b/node-agent/WxAgent.Service/AgentService.cs @@ -138,7 +138,9 @@ public sealed class AgentService(IAgentBackend backend, ServiceSecurity security { var kind = RequireText(request.Kind, "kind", 80); var accountId = RequireText(request.AccountId, "accountId", 200); - var text = PrepareText(request.Text); + var text = string.Equals(kind, "send-text", StringComparison.Ordinal) + ? PrepareLongText(request.Text) + : PrepareText(request.Text); var idempotencyKey = RequireText(request.IdempotencyKey, "idempotencyKey", 128); if (!request.Confirmed) throw new ServiceException("ConfirmationRequired", 409, "Explicit send confirmation is required."); @@ -271,6 +273,12 @@ public sealed class AgentService(IAgentBackend backend, ServiceSecurity security return value; } + private static string PrepareLongText(string? value) + { + try { return WechatTextInput.PrepareLongText(value ?? string.Empty); } + catch (WxAgentException exception) { throw new ServiceException("InvalidRequest", 400, exception.Message); } + } + private static string PrepareText(string? value) { try { return WechatTextInput.Prepare(value ?? string.Empty); } diff --git a/node-agent/WxAgent.Service/RemoteAgentHostedService.cs b/node-agent/WxAgent.Service/RemoteAgentHostedService.cs index bd72097..ace1e69 100644 --- a/node-agent/WxAgent.Service/RemoteAgentHostedService.cs +++ b/node-agent/WxAgent.Service/RemoteAgentHostedService.cs @@ -841,7 +841,7 @@ public sealed class RemoteAgentHostedService( JsonElement Content, IReadOnlyList ChatScopes); - private static async Task ReplayUnreportedResultsAsync( + private async Task ReplayUnreportedResultsAsync( RemoteControlClient client, RemoteTaskLedger ledger, ReportingConfig reporting, @@ -851,7 +851,7 @@ public sealed class RemoteAgentHostedService( await ReportResultAsync(client, ledger, pending.Result, reporting, cancellationToken, pending.ReportingScopes); } - private static async Task ReportResultAsync( + private async Task ReportResultAsync( RemoteControlClient client, RemoteTaskLedger ledger, RemoteTaskResult result, @@ -859,8 +859,16 @@ public sealed class RemoteAgentHostedService( CancellationToken cancellationToken, IReadOnlyList? chatScopes = null) { - await client.SendTaskResultAsync(result, reporting, chatScopes: chatScopes, cancellationToken: cancellationToken); - ledger.MarkReported(result.TaskId); + try + { + await client.SendTaskResultAsync(result, reporting, chatScopes: chatScopes, cancellationToken: cancellationToken); + ledger.MarkReported(result.TaskId); + } + catch (RemoteClientException exception) when (exception.StatusCode == 404 && exception.Code == "TaskNotFound") + { + ledger.MarkResultTaskNotFound(result.TaskId); + logger.LogWarning("Remote task result no longer exists on the control plane; result retained locally and will not be retried."); + } } private async Task ReadSnapshotAsync(RemoteAgentOptions remote, CancellationToken cancellationToken) @@ -972,8 +980,16 @@ public sealed class RemoteAgentHostedService( || confirmed.ValueKind != JsonValueKind.True) return false; targetId = target.GetString() ?? ""; - text = content.GetString() ?? ""; - return targetId.Length is > 0 and <= 512 && text.Length is > 0 and <= 4000; + if (targetId.Length is 0 or > 512) return false; + try + { + text = WechatTextInput.PrepareLongText(content.GetString() ?? ""); + return true; + } + catch (WxAgentException) + { + return false; + } } private static bool GetBoolean(JsonElement value, string propertyName) => diff --git a/node-agent/WxAgent.Service/wwwroot/index.html b/node-agent/WxAgent.Service/wwwroot/index.html index b870335..9837b5b 100644 --- a/node-agent/WxAgent.Service/wwwroot/index.html +++ b/node-agent/WxAgent.Service/wwwroot/index.html @@ -50,7 +50,7 @@

会话

当前账号
-

回复

发送能力检查中…
写能力尚未开放。
+

回复

发送能力检查中…
写能力尚未开放。

可见消息

只显示当前可读取的数据
diff --git a/node-agent/WxAgent.Windows/WechatChatClient.Management.cs b/node-agent/WxAgent.Windows/WechatChatClient.Management.cs index 4d17d69..f9ce99f 100644 --- a/node-agent/WxAgent.Windows/WechatChatClient.Management.cs +++ b/node-agent/WxAgent.Windows/WechatChatClient.Management.cs @@ -277,6 +277,14 @@ public static partial class WechatChatClient if (memberName.Any(char.IsControl) || memberName.Length > 100 || message is not null && (message.Any(char.IsControl) || message.Length > 4000)) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Mention names and text must be single-line, with no control characters."); + var groupMemberNames = new[] { memberName }; + if (!string.Equals(memberName, "所有人", StringComparison.Ordinal)) + { + var members = await GetGroupMembersPageAsync(session, limit: 10000, offset: 0, cancellationToken: cancellationToken).ConfigureAwait(false); + if (members.HasMore) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The group member list exceeds the safe mention-selection bound; no send was attempted."); + groupMemberNames = members.Members.Select(member => member.DisplayName).ToArray(); + } await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); var input = FindByAutomationId(main, WechatLocators.ChatInput) ?? throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Chat input was not found."); @@ -284,7 +292,7 @@ public static partial class WechatChatClient throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before mention input."); if (!string.IsNullOrEmpty(input.AsTextBox().Text)) throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The conversation contains an existing draft; it was not overwritten."); - var baseline = ReadVisible(main).Select(item => item.Fingerprint).ToHashSet(StringComparer.Ordinal); + var baseline = CountFingerprints(ReadVisible(main)); if (main.FindAllDescendants(cf => cf.ByAutomationId(WechatLocators.MentionPopover)) .Any(element => !element.Properties.IsOffscreen.ValueOrDefault)) throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "An existing mention popup must be dismissed before starting a new operation."); @@ -299,14 +307,17 @@ public static partial class WechatChatClient var lists = popovers[0].FindAllDescendants(cf => cf.ByAutomationId(WechatLocators.MentionList)); if (lists.Length != 1) throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "A unique mention list was not found; no send was attempted."); - var options = lists[0].FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem).And(cf.ByName(memberName))) - .Where(element => !element.Properties.IsOffscreen.ValueOrDefault && !element.BoundingRectangle.IsEmpty) - .ToArray(); - if (options.Length != 1) - throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "A unique mention option in a fresh WeChat popup was not found; no send was attempted."); + var options = lists[0].FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem)); + var optionSnapshots = options.Select(element => ( + Name: (string?)SafeName(element), + IsOffscreen: element.Properties.IsOffscreen.ValueOrDefault, + HasBounds: !element.BoundingRectangle.IsEmpty)).ToArray(); + var optionIndex = WechatMentionOptionSelection.FindUniqueVisibleOptionIndex(groupMemberNames, optionSnapshots, memberName); + if (optionIndex < 0) + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "A unique visible mention option was not found; no send was attempted."); if (!IsListeningSession(main, session, independent: false)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before mention selection."); - ClickCenter(options[0]); + ClickCenter(options[optionIndex]); await Task.Delay(100, cancellationToken).ConfigureAwait(false); input = FindByAutomationId(main, WechatLocators.ChatInput) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Chat input disappeared after mention selection."); diff --git a/node-agent/WxAgent.Windows/WechatChatClient.cs b/node-agent/WxAgent.Windows/WechatChatClient.cs index 5f9b497..c0f98b7 100644 --- a/node-agent/WxAgent.Windows/WechatChatClient.cs +++ b/node-agent/WxAgent.Windows/WechatChatClient.cs @@ -112,7 +112,7 @@ public static partial class WechatChatClient public static async Task SendTextAsync(string text, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) { - text = WechatTextInput.Prepare(text); + var segments = WechatTextInput.SplitForSend(text); ArgumentException.ThrowIfNullOrWhiteSpace(session); await CommandQueue.WaitAsync(cancellationToken); @@ -122,30 +122,56 @@ public static partial class WechatChatClient var window = AttachWindow(automation, cancellationToken); await OpenNamedSessionAsync(window, session, cancellationToken).ConfigureAwait(false); await Task.Delay(500, cancellationToken); - if (!IsListeningSession(window, session, independent: false)) - throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested conversation changed before input."); - var baseline = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal); + ChatMessageSnapshot? lastConfirmed = null; - var input = FindByAutomationId(window, WechatLocators.ChatInput) - ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found."); - var inputBox = input.AsTextBox(); - if (!string.IsNullOrEmpty(inputBox.Text)) - throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The conversation contains an existing draft; it was not overwritten."); - ExecuteInputStep("set-input-value", () => inputBox.Text = text); - await Task.Delay(150, cancellationToken); - if (!IsListeningSession(window, session, independent: false) || !string.Equals(inputBox.Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal)) + for (var index = 0; index < segments.Count; index++) { - throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The chat input did not contain the exact requested text; send was aborted."); + var sendAttempted = false; + try + { + if (index > 0) + window = AttachWindow(automation, cancellationToken); + if (!IsListeningSession(window, session, independent: false)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, $"The requested conversation changed before text segment {index + 1}/{segments.Count}."); + + var segment = segments[index]; + var baseline = CountFingerprints(ReadVisible(window)); + var input = FindByAutomationId(window, WechatLocators.ChatInput) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found."); + var inputBox = input.AsTextBox(); + if (!string.IsNullOrEmpty(inputBox.Text)) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The conversation contains an existing draft; it was not overwritten."); + ExecuteInputStep("set-input-value", () => inputBox.Text = segment); + await Task.Delay(150, cancellationToken); + if (!IsListeningSession(window, session, independent: false) || !string.Equals(inputBox.Text.ReplaceLineEndings("\n"), segment, StringComparison.Ordinal)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, $"Text segment {index + 1}/{segments.Count} did not appear exactly in the chat input; send was aborted."); + + // Setting input text can recreate the send control; acquire it only after the input mutation. + var send = window.FindAllDescendants().FirstOrDefault(element => + SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found after input."); + sendAttempted = true; + ExecuteInputStep("click-send", () => ClickCenter(send)); + + lastConfirmed = await WaitForMessageAsync(segment, session, baseline, TimeSpan.FromSeconds(20), cancellationToken); + if (lastConfirmed is null) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, + $"Text segment {index + 1}/{segments.Count} was not confirmed; earlier segments may already have been sent. Do not retry blindly."); + } + catch (Exception exception) when (sendAttempted && exception is not OutOfMemoryException) + { + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, + $"Text segment {index + 1}/{segments.Count} send result is uncertain; it may have been delivered and {index} earlier segment(s) were confirmed. Do not retry the whole text blindly."); + } + catch (Exception exception) when (index > 0 && exception is not OutOfMemoryException) + { + var cause = exception is WxAgentException agentException ? agentException.Code.ToString() : exception.GetType().Name; + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, + $"Text sending stopped after {index} confirmed segment(s) (cause {cause}); earlier text may have been delivered. Do not retry the whole text blindly."); + } } - // Setting input text can recreate the send control; acquire it only after the input mutation. - var send = window.FindAllDescendants().FirstOrDefault(element => - SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send) - ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found after input."); - ExecuteInputStep("click-send", () => ClickCenter(send)); - - var confirmed = await WaitForMessageAsync(text, session, baseline, TimeSpan.FromSeconds(20), cancellationToken); - return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The text was entered but no matching visible message confirmed the send result."); + return lastConfirmed!; } finally { @@ -169,7 +195,7 @@ public static partial class WechatChatClient { throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested conversation changed before quoting."); } - var before = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal); + var before = CountFingerprints(ReadVisible(window)); var list = FindByAutomationId(window, WechatLocators.MessageList) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found."); var draft = FindByAutomationId(window, WechatLocators.ChatInput) @@ -229,6 +255,34 @@ public static partial class WechatChatClient public static Task SendImageAsync(string path, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) => SendAttachmentAsync(path, asImage: true, session, cancellationToken); + public static async Task> SendFilesAsync( + IReadOnlyList? paths, CancellationToken cancellationToken, + string session = WechatLocators.FileTransferAssistant) + { + ArgumentException.ThrowIfNullOrWhiteSpace(session); + var fullPaths = WechatAttachmentInput.PrepareFilePaths(paths); + var confirmed = new List(fullPaths.Count); + foreach (var fullPath in fullPaths) + { + try + { + confirmed.Add(await SendAttachmentAsync(fullPath, WechatAttachmentInput.IsImagePath(fullPath), session, cancellationToken).ConfigureAwait(false)); + } + catch (WxAgentException exception) when (confirmed.Count > 0) + { + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, + $"Attachment batch stopped after {confirmed.Count} confirmed item(s) (cause {exception.Code}); one or more attachments may already have been sent. Do not retry the whole batch blindly."); + } + catch (OperationCanceledException) when (confirmed.Count > 0) + { + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, + $"Attachment batch was cancelled after {confirmed.Count} confirmed item(s); one or more attachments may already have been sent. Do not retry the whole batch blindly."); + } + } + + return confirmed; + } + private static async Task SendAttachmentAsync(string path, bool asImage, string session, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(session); @@ -249,7 +303,7 @@ public static partial class WechatChatClient { throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested conversation changed before attachment input."); } - var before = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal); + var before = CountFingerprints(ReadVisible(window)); var input = FindByAutomationId(window, WechatLocators.ChatInput) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found."); var inputText = input.AsTextBox().Text; @@ -275,11 +329,20 @@ public static partial class WechatChatClient var send = refreshedWindow.FindAllDescendants().FirstOrDefault(element => SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found after attachment paste."); - ClickCenter(send); - var confirmed = await WaitForNewMessageAsync(before, session, - asImage ? ChatMessageType.Image : ChatMessageType.File, asImage ? null : Path.GetFileName(fullPath), - TimeSpan.FromSeconds(30), cancellationToken); - return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The attachment selection completed but no matching new visible message confirmed the send result."); + try + { + ClickCenter(send); + var confirmed = await WaitForNewMessageAsync(before, session, + asImage ? ChatMessageType.Image : ChatMessageType.File, asImage ? null : Path.GetFileName(fullPath), + TimeSpan.FromSeconds(30), cancellationToken); + return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, + "The attachment may have been sent, but no matching new visible message confirmed it. Do not retry blindly."); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, + "The attachment send was attempted, but its result could not be confirmed. It may have been delivered; do not retry blindly."); + } } finally { @@ -657,7 +720,7 @@ public static partial class WechatChatClient } private static async Task WaitForNewMessageAsync( - IReadOnlySet previous, + IReadOnlyDictionary previousCounts, string session, ChatMessageType type, string? expectedText, @@ -673,10 +736,14 @@ public static partial class WechatChatClient var window = WechatDoctor.FindWechatWindow(automation); if (window is not null && !IsListeningSession(window, session, independent: false)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed while confirming the send; no retry was attempted."); - var message = window is null ? null : ReadVisible(window).LastOrDefault(item => + IReadOnlyList visible = window is null + ? Array.Empty() + : ReadVisible(window); + var currentCounts = CountFingerprints(visible); + var message = visible.LastOrDefault(item => mentionMember is null - ? WechatSendConfirmation.Matches(item, previous, type, expectedText) - : WechatSendConfirmation.MatchesMention(item, previous, mentionMember, expectedText)); + ? WechatSendConfirmation.Matches(item, previousCounts, currentCounts, type, expectedText) + : WechatSendConfirmation.MatchesMention(item, previousCounts, currentCounts, mentionMember, expectedText)); if (message is not null) return message; await Task.Delay(250, cancellationToken); } @@ -687,7 +754,7 @@ public static partial class WechatChatClient private static async Task WaitForMessageAsync( string text, string session, - IReadOnlySet baseline, + IReadOnlyDictionary baseline, TimeSpan timeout, CancellationToken cancellationToken) { @@ -699,10 +766,13 @@ public static partial class WechatChatClient var window = WechatDoctor.FindWechatWindow(freshAutomation); if (window is not null && !IsListeningSession(window, session, independent: false)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed while confirming the send; no retry was attempted."); - var match = window is null - ? null - : ReadVisible(window).LastOrDefault(message => - string.Equals(message.Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal) && !baseline.Contains(message.Fingerprint)); + IReadOnlyList visible = window is null + ? Array.Empty() + : ReadVisible(window); + var visibleCounts = CountFingerprints(visible); + var match = visible.LastOrDefault(message => + string.Equals(message.Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal) + && WechatSendConfirmation.IsNewOccurrence(message.Fingerprint, baseline, visibleCounts)); if (match is not null) { return match; @@ -714,6 +784,10 @@ public static partial class WechatChatClient return null; } + private static Dictionary CountFingerprints(IEnumerable messages) => + messages.GroupBy(message => message.Fingerprint, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + private static IReadOnlyList ReadVisible(AutomationElement window) { var list = FindByAutomationId(window, WechatLocators.MessageList) diff --git a/tests/node-agent/WxAgent.Core.Tests/RemoteControlTests.cs b/tests/node-agent/WxAgent.Core.Tests/RemoteControlTests.cs index 80ede91..4dd7f51 100644 --- a/tests/node-agent/WxAgent.Core.Tests/RemoteControlTests.cs +++ b/tests/node-agent/WxAgent.Core.Tests/RemoteControlTests.cs @@ -334,6 +334,21 @@ public sealed class RemoteQueueAndLedgerTests var scope = Assert.Single(pending.ReportingScopes); Assert.Equal("chat-a", scope.ChatId); Assert.Equal(ReportingChatType.Private, scope.ChatType); + + reloaded.MarkResultTaskNotFound(pending.Result.TaskId); + Assert.Empty(reloaded.UnreportedResults()); + Assert.Empty(reloaded.UnreportedResultsWithScopes()); + Assert.Equal(1, reloaded.UnreportableResultCount()); + Assert.True(reloaded.TryGet("task-read", out var retained)); + Assert.NotNull(retained); + Assert.False(retained.Reported); + Assert.Equal("TaskNotFound", retained.ResultReportErrorCode); + Assert.NotNull(retained.Result?.Content); + Assert.Single(retained.ReportingScopes); + + var terminal = new RemoteTaskLedger(path); + Assert.Equal(1, terminal.UnreportableResultCount()); + Assert.Empty(terminal.UnreportedResultsWithScopes()); } finally { directory.Delete(true); } } diff --git a/tests/node-agent/WxAgent.Core.Tests/WechatAttachmentInputTests.cs b/tests/node-agent/WxAgent.Core.Tests/WechatAttachmentInputTests.cs new file mode 100644 index 0000000..3492d4a --- /dev/null +++ b/tests/node-agent/WxAgent.Core.Tests/WechatAttachmentInputTests.cs @@ -0,0 +1,62 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatAttachmentInputTests +{ + [Fact] + public void PrepareFilePathsRejectsOutOfRangeBatchesBeforeAnySend() + { + var single = Assert.Throws(() => WechatAttachmentInput.PrepareFilePaths(["one.txt"])); + var tooMany = Assert.Throws(() => WechatAttachmentInput.PrepareFilePaths( + Enumerable.Repeat("unused.txt", WechatAttachmentInput.MaxBatchSize + 1).ToArray())); + + Assert.Equal(WxAgentErrorCode.InvalidArgument, single.Code); + Assert.Equal(WxAgentErrorCode.InvalidArgument, tooMany.Code); + } + + [Fact] + public void PrepareFilePathsValidatesEveryFileBeforeReturningCanonicalPaths() + { + var directory = Directory.CreateTempSubdirectory(); + try + { + var first = Path.Combine(directory.FullName, "first.pdf"); + var second = Path.Combine(directory.FullName, "second.png"); + File.WriteAllText(first, "first"); + File.WriteAllText(second, "second"); + + var paths = WechatAttachmentInput.PrepareFilePaths([first, second]); + + Assert.Equal([Path.GetFullPath(first), Path.GetFullPath(second)], paths); + Assert.True(WechatAttachmentInput.IsImagePath(second)); + Assert.True(WechatAttachmentInput.IsImagePath("photo.JPEG")); + Assert.False(WechatAttachmentInput.IsImagePath(first)); + } + finally + { + directory.Delete(recursive: true); + } + } + + [Fact] + public void PrepareFilePathsRejectsASecondMissingFileBeforeSendingTheFirst() + { + var directory = Directory.CreateTempSubdirectory(); + try + { + var first = Path.Combine(directory.FullName, "first.pdf"); + File.WriteAllText(first, "first"); + + var exception = Assert.Throws(() => WechatAttachmentInput.PrepareFilePaths( + [first, Path.Combine(directory.FullName, "missing.pdf")])); + + Assert.Equal(WxAgentErrorCode.InvalidArgument, exception.Code); + } + finally + { + directory.Delete(recursive: true); + } + } +} diff --git a/tests/node-agent/WxAgent.Core.Tests/WechatMentionConfirmationTests.cs b/tests/node-agent/WxAgent.Core.Tests/WechatMentionConfirmationTests.cs index 6d17694..3945130 100644 --- a/tests/node-agent/WxAgent.Core.Tests/WechatMentionConfirmationTests.cs +++ b/tests/node-agent/WxAgent.Core.Tests/WechatMentionConfirmationTests.cs @@ -24,8 +24,10 @@ public sealed class WechatMentionConfirmationTests public void RequiresFreshExactMentionAndBody(string text, string member, bool expected) { var message = new ChatMessageSnapshot(text, "fresh", 0, ChatMessageType.Text); - Assert.Equal(expected, WechatSendConfirmation.MatchesMention(message, new HashSet(), member, "marker")); - Assert.False(WechatSendConfirmation.MatchesMention(message, new HashSet { "fresh" }, member, "marker")); - Assert.False(WechatSendConfirmation.MatchesMention(message with { Type = ChatMessageType.Quote }, new HashSet(), member, "marker")); + var empty = new Dictionary(); + var current = new Dictionary { ["fresh"] = 1 }; + Assert.Equal(expected, WechatSendConfirmation.MatchesMention(message, empty, current, member, "marker")); + Assert.False(WechatSendConfirmation.MatchesMention(message, current, current, member, "marker")); + Assert.False(WechatSendConfirmation.MatchesMention(message with { Type = ChatMessageType.Quote }, empty, current, member, "marker")); } } diff --git a/tests/node-agent/WxAgent.Core.Tests/WechatMentionOptionSelectionTests.cs b/tests/node-agent/WxAgent.Core.Tests/WechatMentionOptionSelectionTests.cs new file mode 100644 index 0000000..05e5032 --- /dev/null +++ b/tests/node-agent/WxAgent.Core.Tests/WechatMentionOptionSelectionTests.cs @@ -0,0 +1,40 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatMentionOptionSelectionTests +{ + [Fact] + public void SelectsOnlyAUniqueVisibleOptionForAUniqueDatabaseDisplayName() + { + string?[] members = ["Alice", "Bob"]; + (string? Name, bool IsOffscreen, bool HasBounds)[] options = + [ + ("Alice", false, true), + ("Bob", false, true) + ]; + + Assert.Equal(0, WechatMentionOptionSelection.FindUniqueVisibleOptionIndex(members, options, "Alice")); + } + + [Fact] + public void RejectsDatabaseDuplicatesEvenWhenOnlyOneCandidateIsVisible() + { + string?[] members = ["Alice", "Alice"]; + (string? Name, bool IsOffscreen, bool HasBounds)[] options = [("Alice", false, true)]; + + Assert.Equal(-1, WechatMentionOptionSelection.FindUniqueVisibleOptionIndex(members, options, "Alice")); + } + + [Fact] + public void RejectsOffscreenOrDuplicateUiCandidates() + { + string?[] members = ["Alice"]; + (string? Name, bool IsOffscreen, bool HasBounds)[] offscreen = [("Alice", true, false)]; + (string? Name, bool IsOffscreen, bool HasBounds)[] duplicate = [("Alice", false, true), ("Alice", true, false)]; + + Assert.Equal(-1, WechatMentionOptionSelection.FindUniqueVisibleOptionIndex(members, offscreen, "Alice")); + Assert.Equal(-1, WechatMentionOptionSelection.FindUniqueVisibleOptionIndex(members, duplicate, "Alice")); + } +} diff --git a/tests/node-agent/WxAgent.Core.Tests/WechatMergedChatTests.cs b/tests/node-agent/WxAgent.Core.Tests/WechatMergedChatTests.cs index e6de4d5..df6eb46 100644 --- a/tests/node-agent/WxAgent.Core.Tests/WechatMergedChatTests.cs +++ b/tests/node-agent/WxAgent.Core.Tests/WechatMergedChatTests.cs @@ -37,6 +37,27 @@ public sealed class WechatMergedChatTests Assert.DoesNotContain("must-not-be-exported", JsonSerializer.Serialize(result)); } + [Fact] + public void PreservesMixedImageVideoFileAndNestedAttachmentMetadata() + { + static XElement Attachment(int type, string title, string format, long size) => new("dataitem", + new XAttribute("datatype", type), new XElement("datatitle", title), new XElement("datafmt", format), new XElement("datasize", size)); + + var image = Attachment(2, "image.jpg", "jpg", 12); + var video = Attachment(4, "clip.mp4", "mp4", 34); + var file = Attachment(8, "notes.pdf", "pdf", 56); + var nested = new XElement("dataitem", new XAttribute("datatype", 17), Record(Attachment(2, "nested.png", "png", 78))); + + var result = WechatMergedChatParser.Parse(Record(image, video, file, nested).ToString()); + + Assert.Equal([2, 4, 8, 17], result.Messages.Select(message => message.DataType)); + Assert.Equal([12L, 34L, 56L], result.Messages.Take(3).Select(message => message.SizeBytes)); + Assert.Equal(["image.jpg", "clip.mp4", "notes.pdf"], result.Messages.Take(3).Select(message => message.Title)); + var nestedMessage = result.Messages[3].NestedRecord!.Messages[0]; + Assert.Equal("3/0", nestedMessage.Path); + Assert.Equal(2, nestedMessage.DataType); + } + [Fact] public void RejectsTruncationMissingPayloadAndInvalidTimestamp() { diff --git a/tests/node-agent/WxAgent.Core.Tests/WechatSendConfirmationOccurrenceTests.cs b/tests/node-agent/WxAgent.Core.Tests/WechatSendConfirmationOccurrenceTests.cs new file mode 100644 index 0000000..e22708a --- /dev/null +++ b/tests/node-agent/WxAgent.Core.Tests/WechatSendConfirmationOccurrenceTests.cs @@ -0,0 +1,19 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatSendConfirmationOccurrenceTests +{ + [Fact] + public void IdenticalFingerprintIsNewOnlyWhenItsVisibleCountIncreases() + { + var baseline = new Dictionary { ["same-message"] = 1 }; + var unchanged = new Dictionary { ["same-message"] = 1 }; + var appended = new Dictionary { ["same-message"] = 2 }; + + Assert.False(WechatSendConfirmation.IsNewOccurrence("same-message", baseline, unchanged)); + Assert.True(WechatSendConfirmation.IsNewOccurrence("same-message", baseline, appended)); + Assert.False(WechatSendConfirmation.IsNewOccurrence("other-message", baseline, appended)); + } +} diff --git a/tests/node-agent/WxAgent.Core.Tests/WechatSendConfirmationTests.cs b/tests/node-agent/WxAgent.Core.Tests/WechatSendConfirmationTests.cs index a198d9f..6cef4c7 100644 --- a/tests/node-agent/WxAgent.Core.Tests/WechatSendConfirmationTests.cs +++ b/tests/node-agent/WxAgent.Core.Tests/WechatSendConfirmationTests.cs @@ -8,12 +8,13 @@ public sealed class WechatSendConfirmationTests [Fact] public void RequiresFreshMatchingTypeAndExactReplyText() { - var baseline = new HashSet { "old" }; + var baseline = new Dictionary { ["old"] = 1 }; + var current = new Dictionary { ["old"] = 1, ["new"] = 1 }; var reply = new ChatMessageSnapshot("reply", "new", 0, ChatMessageType.Quote); - Assert.True(WechatSendConfirmation.Matches(reply, baseline, ChatMessageType.Quote, "reply")); - Assert.False(WechatSendConfirmation.Matches(reply with { Fingerprint = "old" }, baseline, ChatMessageType.Quote, "reply")); - Assert.False(WechatSendConfirmation.Matches(reply with { Type = ChatMessageType.Text }, baseline, ChatMessageType.Quote, "reply")); - Assert.False(WechatSendConfirmation.Matches(reply with { Text = "another reply" }, baseline, ChatMessageType.Quote, "reply")); + Assert.True(WechatSendConfirmation.Matches(reply, baseline, current, ChatMessageType.Quote, "reply")); + Assert.False(WechatSendConfirmation.Matches(reply with { Fingerprint = "old" }, baseline, current, ChatMessageType.Quote, "reply")); + Assert.False(WechatSendConfirmation.Matches(reply with { Type = ChatMessageType.Text }, baseline, current, ChatMessageType.Quote, "reply")); + Assert.False(WechatSendConfirmation.Matches(reply with { Text = "another reply" }, baseline, current, ChatMessageType.Quote, "reply")); } [Theory] @@ -25,15 +26,18 @@ public sealed class WechatSendConfirmationTests public void FileConfirmationRequiresExactFilenameLine(string text, bool expected) { var message = new ChatMessageSnapshot(text, "new", 0, ChatMessageType.File); - Assert.Equal(expected, WechatSendConfirmation.Matches(message, new HashSet(), ChatMessageType.File, "report.txt")); + Assert.Equal(expected, WechatSendConfirmation.Matches(message, new Dictionary(), + new Dictionary { ["new"] = 1 }, ChatMessageType.File, "report.txt")); } [Fact] public void ImageConfirmationRejectsOtherTypesAndOldRows() { var message = new ChatMessageSnapshot("[图片]", "new", 0, ChatMessageType.Image); - Assert.True(WechatSendConfirmation.Matches(message, new HashSet(), ChatMessageType.Image, null)); - Assert.False(WechatSendConfirmation.Matches(message, new HashSet { "new" }, ChatMessageType.Image, null)); - Assert.False(WechatSendConfirmation.Matches(message with { Type = ChatMessageType.Text }, new HashSet(), ChatMessageType.Image, null)); + var empty = new Dictionary(); + var current = new Dictionary { ["new"] = 1 }; + Assert.True(WechatSendConfirmation.Matches(message, empty, current, ChatMessageType.Image, null)); + Assert.False(WechatSendConfirmation.Matches(message, current, current, ChatMessageType.Image, null)); + Assert.False(WechatSendConfirmation.Matches(message with { Type = ChatMessageType.Text }, empty, current, ChatMessageType.Image, null)); } } diff --git a/tests/node-agent/WxAgent.Core.Tests/WechatTextInputSegmentationTests.cs b/tests/node-agent/WxAgent.Core.Tests/WechatTextInputSegmentationTests.cs new file mode 100644 index 0000000..1c8b6f0 --- /dev/null +++ b/tests/node-agent/WxAgent.Core.Tests/WechatTextInputSegmentationTests.cs @@ -0,0 +1,47 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatTextInputSegmentationTests +{ + [Fact] + public void SplitForSendSplitsLongTextAtTheSingleMessageBoundary() + { + var text = new string('x', WechatTextInput.MaxSegmentLength + 1); + + var segments = WechatTextInput.SplitForSend(text); + + Assert.Equal(2, segments.Count); + Assert.Equal(WechatTextInput.MaxSegmentLength, segments[0].Length); + Assert.Equal(1, segments[1].Length); + Assert.Equal(text, string.Concat(segments)); + Assert.Throws(() => WechatTextInput.Prepare(text)); + } + + [Fact] + public void SplitForSendDoesNotBreakACombiningTextElement() + { + var prefix = new string('x', WechatTextInput.MaxSegmentLength - 1); + var combiningElement = "e\u0301"; + var text = prefix + combiningElement + "tail"; + + var segments = WechatTextInput.SplitForSend(text); + + Assert.Equal(2, segments.Count); + Assert.Equal(prefix, segments[0]); + Assert.StartsWith(combiningElement, segments[1], StringComparison.Ordinal); + Assert.All(segments, segment => Assert.InRange(segment.Length, 1, WechatTextInput.MaxSegmentLength)); + Assert.Equal(text, string.Concat(segments)); + } + + [Fact] + public void SplitForSendRejectsTextBeyondTheBoundedTotalLength() + { + var text = new string('x', WechatTextInput.MaxTextLength + 1); + + var exception = Assert.Throws(() => WechatTextInput.SplitForSend(text)); + + Assert.Equal(WxAgentErrorCode.InvalidArgument, exception.Code); + } +} diff --git a/tests/node-agent/WxAgent.Core.Tests/WechatTextInputTests.cs b/tests/node-agent/WxAgent.Core.Tests/WechatTextInputTests.cs index 1228adc..03677c7 100644 --- a/tests/node-agent/WxAgent.Core.Tests/WechatTextInputTests.cs +++ b/tests/node-agent/WxAgent.Core.Tests/WechatTextInputTests.cs @@ -28,7 +28,8 @@ public sealed class WechatTextInputTests var parsed = Assert.Single(VisibleMessageParser.Parse([reply + "\n引用 Alice 的消息 : original"])); Assert.Equal(reply, parsed.Text); Assert.Equal(ChatMessageType.Quote, parsed.Type); - Assert.True(WechatSendConfirmation.Matches(parsed, new HashSet(), ChatMessageType.Quote, reply)); + Assert.True(WechatSendConfirmation.Matches(parsed, new Dictionary(), + new Dictionary { [parsed.Fingerprint] = 1 }, ChatMessageType.Quote, reply)); Assert.Equal("original", parsed.Quote!.Text); } } diff --git a/tests/node-agent/WxAgent.Service.Tests/BroadcastOperationTests.cs b/tests/node-agent/WxAgent.Service.Tests/BroadcastOperationTests.cs index d13ce16..0f162be 100644 --- a/tests/node-agent/WxAgent.Service.Tests/BroadcastOperationTests.cs +++ b/tests/node-agent/WxAgent.Service.Tests/BroadcastOperationTests.cs @@ -13,6 +13,7 @@ public sealed class BroadcastOperationTests private sealed class Backend(bool failSecond = false) : IAgentBackend { public List SentTargets { get; } = []; + public List SentTexts { get; } = []; public IReadOnlyList Capabilities => [ @@ -35,6 +36,7 @@ public sealed class BroadcastOperationTests if (failSecond && targetId == "session-b") throw new ServiceException("TargetUnavailable", 409, "Target is no longer available."); SentTargets.Add(targetId); + SentTexts.Add(text); return Task.CompletedTask; } } @@ -136,6 +138,53 @@ public sealed class BroadcastOperationTests } } + [Fact] + public async Task SendTextAcceptsBoundedLongTextWhileBroadcastRetainsSingleMessageLimit() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); + var token = new string('F', 43); + var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir }; + File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] + { + new ServiceCredential("p", ServiceOptions.HashToken(token), ["read", "write"], []) + })); + var backend = new Backend(); + await using var app = ServiceHost.Build(options, backend, b => b.WebHost.UseTestServer()); + try + { + await app.StartAsync(); + using var client = app.GetTestClient(); + client.BaseAddress = new Uri("http://localhost:5088"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + var longText = new string('x', WxAgent.Core.WechatTextInput.MaxSegmentLength + 1); + var submitted = await client.PostAsJsonAsync("/api/v1/operations", new + { + kind = "send-text", accountId = "account-1", targetId = "session-a", text = longText, + idempotencyKey = "send-long-text-1", confirmed = true + }); + Assert.Equal(HttpStatusCode.OK, submitted.StatusCode); + var queued = await submitted.Content.ReadFromJsonAsync(); + var completed = await WaitForTerminalAsync(client, queued.GetProperty("id").GetString()!); + Assert.Equal("Succeeded", completed.GetProperty("state").GetString()); + Assert.Equal(["session-a"], backend.SentTargets); + Assert.Equal([longText], backend.SentTexts); + + var broadcast = await client.PostAsJsonAsync("/api/v1/operations", new + { + kind = "broadcast-text", accountId = "account-1", targets = new[] { "session-a" }, text = longText, + idempotencyKey = "broadcast-long-text-1", confirmed = true, stopOnError = true + }); + Assert.Equal(HttpStatusCode.BadRequest, broadcast.StatusCode); + Assert.Single(backend.SentTargets); + } + finally + { + await app.StopAsync(); + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + Directory.Delete(dir, true); + } + } + private static async Task WaitForTerminalAsync(HttpClient client, string operationId) { for (var i = 0; i < 100; i++)