feat: support long text and safe attachment batches
Build web service image / build (push) Successful in 1m15s

This commit is contained in:
2026-09-30 09:16:36 +08:00
parent a977902b25
commit edba7d0d38
26 changed files with 720 additions and 82 deletions
+39
View File
@@ -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)
}
})
}
}
+11 -1
View File
@@ -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 {
+9 -1
View File
@@ -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)。
@@ -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 破解仍不实施。
+26 -3
View File
@@ -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;
@@ -0,0 +1,37 @@
namespace WxAgent.Core;
public static class WechatAttachmentInput
{
public const int MaxBatchSize = 10;
private static readonly HashSet<string> 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<string> PrepareFilePaths(IReadOnlyList<string>? 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;
}
}
@@ -0,0 +1,25 @@
namespace WxAgent.Core;
public static class WechatMentionOptionSelection
{
public static int FindUniqueVisibleOptionIndex(
IReadOnlyList<string?> 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;
}
}
@@ -4,10 +4,16 @@ public static class WechatSendConfirmation
{
public static bool IsMentionToken(string text) => text.TrimEnd() == "\uFFFC";
public static bool MatchesMention(ChatMessageSnapshot message, IReadOnlySet<string> baseline,
public static bool IsNewOccurrence(string fingerprint,
IReadOnlyDictionary<string, int> baselineCounts, IReadOnlyDictionary<string, int> currentCounts) =>
currentCounts.TryGetValue(fingerprint, out var currentCount)
&& currentCount > (baselineCounts.TryGetValue(fingerprint, out var baselineCount) ? baselineCount : 0);
public static bool MatchesMention(ChatMessageSnapshot message,
IReadOnlyDictionary<string, int> baselineCounts, IReadOnlyDictionary<string, int> 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<string> baseline,
public static bool Matches(ChatMessageSnapshot message,
IReadOnlyDictionary<string, int> baselineCounts, IReadOnlyDictionary<string, int> 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))
+42 -2
View File
@@ -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<string> SplitForSend(string text)
{
var normalized = PrepareLongText(text);
if (normalized.Length <= MaxSegmentLength)
return [normalized];
var textElementStarts = StringInfo.ParseCombiningCharacters(normalized);
var segments = new List<string>();
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;
}
}
+33 -6
View File
@@ -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<string> valueOptions, HashSet<string> flagOptions)
static void ValidateOptions(string[] values, int startIndex, HashSet<string> valueOptions, HashSet<string> flagOptions, HashSet<string>? repeatableValueOptions = null)
{
var seen = new HashSet<string>(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<string> GetOptions(string[] values, string name)
{
var options = new List<string>();
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 <name> --message <text> [--timeout 30]
chat send-url-card --to <name> --url <http-url> [--message <text>] [--timeout 60]
chat send-audio --to <name> --path <audio-file> --confirm CONFIRM [--timeout 60]
chat send --text <text-up-to-4000-chars; multiline-supported> [--session <name>] [--timeout 30]
chat send --text <text-up-to-20000-chars; split-into-4000-char-messages; multiline-supported> [--session <name>] [--timeout 120]
chat reply-latest --text <text-up-to-4000-chars; multiline-supported> [--session <name>] [--timeout 60]
chat send-file --path <file> [--session <name>] [--timeout 60]
chat send-image --path <image> [--session <name>] [--timeout 60]
chat send-files --path <file> --path <file> ... [--session <name>] [--timeout <seconds>] (2-10; images sent as images)
chat mention --session <name> --member <display-name> --text <text> --confirm CONFIRM [--timeout 60]
chat read [--limit 20] [--include-content] [--timeout 30]
chat history [--session <name>] [--limit 100] [--scrolls 10] [--include-content] [--timeout 60]
@@ -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)
};
}
+9 -1
View File
@@ -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); }
@@ -841,7 +841,7 @@ public sealed class RemoteAgentHostedService(
JsonElement Content,
IReadOnlyList<RemoteReportingScope> 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<RemoteReportingScope>? 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<BackendSnapshot> 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) =>
@@ -50,7 +50,7 @@
<label class="toggle"><input id="content" type="checkbox"> 显示正文 <span class="muted">需要 content 权限</span></label>
</section>
<section class="panel"><div class="section-heading"><h2>会话</h2><span class="muted">当前账号</span></div><div id="sessions" class="list"></div></section>
<section class="panel reply-panel"><div class="section-heading"><h2>回复</h2><span id="replyCapability" class="muted">发送能力检查中…</span></div><div class="reply-form"><label for="replyTarget">目标会话</label><select id="replyTarget"><option value="">先选择会话</option></select><label for="replyText">文本</label><textarea id="replyText" rows="4" maxlength="4000" placeholder="输入要发送的文本" disabled></textarea><div class="reply-actions"><button id="sendReply" disabled>发送</button><span id="replyState" class="muted">写能力尚未开放。</span></div></div></section>
<section class="panel reply-panel"><div class="section-heading"><h2>回复</h2><span id="replyCapability" class="muted">发送能力检查中…</span></div><div class="reply-form"><label for="replyTarget">目标会话</label><select id="replyTarget"><option value="">先选择会话</option></select><label for="replyText">文本</label><textarea id="replyText" rows="4" maxlength="20000" placeholder="输入要发送的文本" disabled></textarea><div class="reply-actions"><button id="sendReply" disabled>发送</button><span id="replyState" class="muted">写能力尚未开放。</span></div></div></section>
<section class="panel"><div class="section-heading"><h2>可见消息</h2><span class="muted">只显示当前可读取的数据</span></div><div id="messages" class="list"></div></section>
</section>
@@ -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.");
+111 -37
View File
@@ -112,7 +112,7 @@ public static partial class WechatChatClient
public static async Task<ChatMessageSnapshot> 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<ChatMessageSnapshot> SendImageAsync(string path, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) =>
SendAttachmentAsync(path, asImage: true, session, cancellationToken);
public static async Task<IReadOnlyList<ChatMessageSnapshot>> SendFilesAsync(
IReadOnlyList<string>? paths, CancellationToken cancellationToken,
string session = WechatLocators.FileTransferAssistant)
{
ArgumentException.ThrowIfNullOrWhiteSpace(session);
var fullPaths = WechatAttachmentInput.PrepareFilePaths(paths);
var confirmed = new List<ChatMessageSnapshot>(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<ChatMessageSnapshot> 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<ChatMessageSnapshot?> WaitForNewMessageAsync(
IReadOnlySet<string> previous,
IReadOnlyDictionary<string, int> 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<ChatMessageSnapshot> visible = window is null
? Array.Empty<ChatMessageSnapshot>()
: 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<ChatMessageSnapshot?> WaitForMessageAsync(
string text,
string session,
IReadOnlySet<string> baseline,
IReadOnlyDictionary<string, int> 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<ChatMessageSnapshot> visible = window is null
? Array.Empty<ChatMessageSnapshot>()
: 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<string, int> CountFingerprints(IEnumerable<ChatMessageSnapshot> messages) =>
messages.GroupBy(message => message.Fingerprint, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
private static IReadOnlyList<ChatMessageSnapshot> ReadVisible(AutomationElement window)
{
var list = FindByAutomationId(window, WechatLocators.MessageList)
@@ -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); }
}
@@ -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<WxAgentException>(() => WechatAttachmentInput.PrepareFilePaths(["one.txt"]));
var tooMany = Assert.Throws<WxAgentException>(() => 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<WxAgentException>(() => WechatAttachmentInput.PrepareFilePaths(
[first, Path.Combine(directory.FullName, "missing.pdf")]));
Assert.Equal(WxAgentErrorCode.InvalidArgument, exception.Code);
}
finally
{
directory.Delete(recursive: true);
}
}
}
@@ -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<string>(), member, "marker"));
Assert.False(WechatSendConfirmation.MatchesMention(message, new HashSet<string> { "fresh" }, member, "marker"));
Assert.False(WechatSendConfirmation.MatchesMention(message with { Type = ChatMessageType.Quote }, new HashSet<string>(), member, "marker"));
var empty = new Dictionary<string, int>();
var current = new Dictionary<string, int> { ["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"));
}
}
@@ -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"));
}
}
@@ -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()
{
@@ -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<string, int> { ["same-message"] = 1 };
var unchanged = new Dictionary<string, int> { ["same-message"] = 1 };
var appended = new Dictionary<string, int> { ["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));
}
}
@@ -8,12 +8,13 @@ public sealed class WechatSendConfirmationTests
[Fact]
public void RequiresFreshMatchingTypeAndExactReplyText()
{
var baseline = new HashSet<string> { "old" };
var baseline = new Dictionary<string, int> { ["old"] = 1 };
var current = new Dictionary<string, int> { ["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<string>(), ChatMessageType.File, "report.txt"));
Assert.Equal(expected, WechatSendConfirmation.Matches(message, new Dictionary<string, int>(),
new Dictionary<string, int> { ["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<string>(), ChatMessageType.Image, null));
Assert.False(WechatSendConfirmation.Matches(message, new HashSet<string> { "new" }, ChatMessageType.Image, null));
Assert.False(WechatSendConfirmation.Matches(message with { Type = ChatMessageType.Text }, new HashSet<string>(), ChatMessageType.Image, null));
var empty = new Dictionary<string, int>();
var current = new Dictionary<string, int> { ["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));
}
}
@@ -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<WxAgentException>(() => 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<WxAgentException>(() => WechatTextInput.SplitForSend(text));
Assert.Equal(WxAgentErrorCode.InvalidArgument, exception.Code);
}
}
@@ -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<string>(), ChatMessageType.Quote, reply));
Assert.True(WechatSendConfirmation.Matches(parsed, new Dictionary<string, int>(),
new Dictionary<string, int> { [parsed.Fingerprint] = 1 }, ChatMessageType.Quote, reply));
Assert.Equal("original", parsed.Quote!.Text);
}
}
@@ -13,6 +13,7 @@ public sealed class BroadcastOperationTests
private sealed class Backend(bool failSecond = false) : IAgentBackend
{
public List<string> SentTargets { get; } = [];
public List<string> SentTexts { get; } = [];
public IReadOnlyList<AgentCapability> 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<JsonElement>();
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<JsonElement> WaitForTerminalAsync(HttpClient client, string operationId)
{
for (var i = 0; i < 100; i++)