feat: support long text and safe attachment batches
Build web service image / build (push) Successful in 1m15s
Build web service image / build (push) Successful in 1m15s
This commit is contained in:
@@ -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
@@ -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
@@ -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 破解仍不实施。
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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++)
|
||||
|
||||
Reference in New Issue
Block a user