From c49f6a10b2c10ad8d1b3e1e0bb70310a67dbb6bc Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 6 Sep 2026 17:10:53 +0800 Subject: [PATCH] feat: generalize session sending with multiline text and structured mentions Add named-session file/image/reply sending, preserve multiline text, and share safe member/all mention handling. Protect existing drafts, bind popup selection to the current window, validate structured mention tokens, and require fresh matching visible messages without automatic resend. Core tests: 121 passed. Windows 4.1.13.63 final validation: 9/9 passed. Record explicit coverage limits and sanitized evidence. --- docs/validation/General-Sending-2026-09-06.md | 54 +++++++++ .../General-Sending-20260906/doctor.json | 6 + .../General-Sending-20260906/mention-all.json | 5 + .../mention-member.json | 5 + .../negative-and-cleanup.json | 27 +++++ .../reply-latest.json | 6 + .../General-Sending-20260906/send-file.json | 6 + .../General-Sending-20260906/send-image.json | 6 + .../send-multiline.json | 6 + .../General-Sending-20260906/smoke.json | 6 + .../General-Sending-20260906/summary.json | 43 +++++++ scripts/windows/Test-GeneralSending.ps1 | 51 ++++++++ src/WxAgent.Core/ChatMessages.cs | 10 +- src/WxAgent.Core/WechatLocators.cs | 2 + src/WxAgent.Core/WechatSendConfirmation.cs | 28 +++++ src/WxAgent.Core/WechatTextInput.cs | 14 +++ src/WxAgent.Host/Program.cs | 33 ++++-- src/WxAgent.Windows/WechatChatClient.M5.cs | 2 +- .../WechatChatClient.Management.cs | 74 +++++++++--- src/WxAgent.Windows/WechatChatClient.cs | 112 +++++++++++------- .../WechatMentionConfirmationTests.cs | 31 +++++ .../WechatSendConfirmationTests.cs | 39 ++++++ .../WechatTextInputTests.cs | 34 ++++++ 23 files changed, 531 insertions(+), 69 deletions(-) create mode 100644 docs/validation/General-Sending-2026-09-06.md create mode 100644 docs/validation/evidence/General-Sending-20260906/doctor.json create mode 100644 docs/validation/evidence/General-Sending-20260906/mention-all.json create mode 100644 docs/validation/evidence/General-Sending-20260906/mention-member.json create mode 100644 docs/validation/evidence/General-Sending-20260906/negative-and-cleanup.json create mode 100644 docs/validation/evidence/General-Sending-20260906/reply-latest.json create mode 100644 docs/validation/evidence/General-Sending-20260906/send-file.json create mode 100644 docs/validation/evidence/General-Sending-20260906/send-image.json create mode 100644 docs/validation/evidence/General-Sending-20260906/send-multiline.json create mode 100644 docs/validation/evidence/General-Sending-20260906/smoke.json create mode 100644 docs/validation/evidence/General-Sending-20260906/summary.json create mode 100644 scripts/windows/Test-GeneralSending.ps1 create mode 100644 src/WxAgent.Core/WechatSendConfirmation.cs create mode 100644 src/WxAgent.Core/WechatTextInput.cs create mode 100644 tests/WxAgent.Core.Tests/WechatMentionConfirmationTests.cs create mode 100644 tests/WxAgent.Core.Tests/WechatSendConfirmationTests.cs create mode 100644 tests/WxAgent.Core.Tests/WechatTextInputTests.cs diff --git a/docs/validation/General-Sending-2026-09-06.md b/docs/validation/General-Sending-2026-09-06.md new file mode 100644 index 0000000..d8e18b6 --- /dev/null +++ b/docs/validation/General-Sending-2026-09-06.md @@ -0,0 +1,54 @@ +# 通用发送增量验收 — 2026-09-06 + +## 环境与发布包 + +- Windows 真机:`10.1.1.101`,微信 `4.1.13.63`,现有已登录交互会话;未重启微信、重新登录或绕过锁屏。 +- 最终发布目录:`C:\Users\Rogee\wx-agent\releases\generic-send-final-20260906T090401`。 +- `WxAgent.Host.exe` SHA-256:`45E9994739D33E0786F3D224E9905DDF7439CCD322E6BAF21B431880706A0229`。 +- 仅操作文件传输助手和用户批准的“消息测试专用群组”;成员提及目标为批准的测试成员。各次发送使用不同标记,不对失败/未确认发送自动重试。 + +## 新增与修复 + +- `chat send-file`、`chat send-image`、`chat reply-latest` 支持 `--session`,默认仍为文件传输助手。 +- `chat send` 与 `chat reply-latest` 支持多行,包括中间空行;统一 CRLF/CR 为 LF,保留正文空行。单条最多 4000 字符,不自动拆分。 +- 新增 `chat mention --session <会话> --member <显示名称> --text <正文> --confirm CONFIRM`。`group at-all` 复用同一提及实现。 +- 提及定位限定于当前绑定窗口内新打开的 `MentionPopover → chat_mention_list → ListItem + 精确名称`,不再搜索整个桌面的同名元素。 +- 真机确认:选中提及对象后,输入框 Value 是结构化对象替代字符 `U+FFFC`,不是 `@姓名`。校验对象后粘贴正文,避免输入法转换,同时保留提及对象。 +- 已有草稿拒绝覆盖;输入、点击发送和结果确认检查会话。附件与引用回复不再把任意新消息当成成功:文件匹配精确文件名行,引用匹配类型与正文,提及匹配显示名称与正文,所有确认要求新指纹。 +- 剪贴板实现复用原 STA lease,新增文本粘贴;M5 附件调用仅跟随类型重命名,并未追加朋友圈操作验收。 + +## 验证结果 + +Linux:`dotnet test tests/WxAgent.Core.Tests -c Release --no-restore`,**121/121 通过**;完整解决方案 Release build 无警告、无错误。新增测试覆盖新旧指纹、错误类型/正文/文件名、结构化提及、Unicode 提及间隔、文本长度/控制字符及多行引用解析。 + +最终包执行 `Test-GeneralSending.ps1 -IncludeMentions`,**9/9 通过**: + +| 检查 | 结果 | +| --- | --- | +| doctor | 输入桌面可用、微信窗口存在、无错误 | +| inspect-ui | 脱敏 UI 树输出成功 | +| smoke | 文件传输助手基线通过 | +| send-multiline | 测试群发送含空行的三行正文,新消息确认 | +| send-file | 测试群发送小文件,新文件名/类型确认 | +| send-image | 测试群发送测试 PNG,新图片类型确认 | +| reply-latest | 测试群引用最新消息并发送含空行正文,引用类型/正文确认 | +| mention-member | 精确选择真实成员提及对象,正文新消息确认 | +| mention-all | 精确选择所有人提及对象,正文新消息确认 | + +## 失败诊断与草稿保护 + +中间候选并非通过记录:最初把弹窗误认为顶层窗口、随后把提及对象误认为文字,均在发送前停止。读取真实 UIA 路径与 Value 后修正;没有把这些失败算作发送成功。 + +- 不存在的成员:`ControlNotFound`,未调用发送。 +- 已有草稿:`InvalidOperationState`,拒绝覆盖。 +- 仅自动清理了精确匹配的测试草稿 `@` 和单个结构化提及对象。 +- 一次键入尝试留下非预期 4 字符草稿,具体转换原因未确认,自动清理拒绝;**用户明确授权后**仅清理该草稿,记录长度和授权,不记录内容。 + +脱敏证据:[`evidence/General-Sending-20260906/`](evidence/General-Sending-20260906/)。完整中间诊断留在本机忽略的 `artifacts/generic-send-current/`,不提交消息正文、完整 UI 树或密钥。 + +## 验收边界 / 尚未覆盖 + +- 可见消息确认不等于对端已收到;图片仅匹配新图片类型,不进行图片内容哈希核对。本次没有把对端收件、多账号消息归属或后台数据库关联算作通过。 +- 提及当前要求名称在弹窗可见列表中唯一;长列表滚动、不可见重名的完整枚举尚未覆盖,不猜选不可见成员。提及附加正文仍限单行。 +- 超过 4000 字符、自动分段、多附件批量发送、真正的 URL 卡片仍未在本增量实现或验收。 +- 导航补齐和合并聊天记录展开尚未由本提交实现;不能把本记录视为四项需求全部完成。 diff --git a/docs/validation/evidence/General-Sending-20260906/doctor.json b/docs/validation/evidence/General-Sending-20260906/doctor.json new file mode 100644 index 0000000..d4b1e63 --- /dev/null +++ b/docs/validation/evidence/General-Sending-20260906/doctor.json @@ -0,0 +1,6 @@ +{ + "CurrentSessionId": 1, + "InputDesktopAvailable": true, + "WindowFound": true, + "Errors": [] +} diff --git a/docs/validation/evidence/General-Sending-20260906/mention-all.json b/docs/validation/evidence/General-Sending-20260906/mention-all.json new file mode 100644 index 0000000..1e769f8 --- /dev/null +++ b/docs/validation/evidence/General-Sending-20260906/mention-all.json @@ -0,0 +1,5 @@ +{ + "Success": true, + "Code": null, + "Message": "visible message confirmed" +} diff --git a/docs/validation/evidence/General-Sending-20260906/mention-member.json b/docs/validation/evidence/General-Sending-20260906/mention-member.json new file mode 100644 index 0000000..1e769f8 --- /dev/null +++ b/docs/validation/evidence/General-Sending-20260906/mention-member.json @@ -0,0 +1,5 @@ +{ + "Success": true, + "Code": null, + "Message": "visible message confirmed" +} diff --git a/docs/validation/evidence/General-Sending-20260906/negative-and-cleanup.json b/docs/validation/evidence/General-Sending-20260906/negative-and-cleanup.json new file mode 100644 index 0000000..72b51b4 --- /dev/null +++ b/docs/validation/evidence/General-Sending-20260906/negative-and-cleanup.json @@ -0,0 +1,27 @@ +{ + "MissingMember": { + "error": "ControlNotFound", + "Message": "A unique mention option in a fresh WeChat popup was not found; no send was attempted." + }, + "ExistingDraft": { + "error": "InvalidOperationState", + "Message": "The conversation contains an existing draft; it was not overwritten." + }, + "ExactOwnedDraftCleanup": { + "DraftGuardPassed": true, + "Cleared": true, + "OwnedDraftLength": 1 + }, + "UserApprovedUnexpectedDraftCleanup": { + "UserApprovedCleanup": true, + "Cleared": true, + "DraftGuardPassed": true, + "ClearedDraftLength": 4 + }, + "ExactStructuredTokenCleanup": { + "UserApprovedCleanup": false, + "Cleared": true, + "DraftGuardPassed": true, + "ClearedDraftLength": 1 + } +} diff --git a/docs/validation/evidence/General-Sending-20260906/reply-latest.json b/docs/validation/evidence/General-Sending-20260906/reply-latest.json new file mode 100644 index 0000000..563dfee --- /dev/null +++ b/docs/validation/evidence/General-Sending-20260906/reply-latest.json @@ -0,0 +1,6 @@ +{ + "Fingerprint": "b787bffca17193219159d001a2cdd7d70d82aaa8628dffa0135173af205f698d", + "Type": "Quote", + "length": 114, + "content": null +} diff --git a/docs/validation/evidence/General-Sending-20260906/send-file.json b/docs/validation/evidence/General-Sending-20260906/send-file.json new file mode 100644 index 0000000..5b4dba6 --- /dev/null +++ b/docs/validation/evidence/General-Sending-20260906/send-file.json @@ -0,0 +1,6 @@ +{ + "Fingerprint": "dcd1b8ceaef77ca58f0656abdf7dce58aadcf997a991d5bb94a88dc3be8d7042", + "Type": "File", + "length": 77, + "content": null +} diff --git a/docs/validation/evidence/General-Sending-20260906/send-image.json b/docs/validation/evidence/General-Sending-20260906/send-image.json new file mode 100644 index 0000000..100deb2 --- /dev/null +++ b/docs/validation/evidence/General-Sending-20260906/send-image.json @@ -0,0 +1,6 @@ +{ + "Fingerprint": "8d879245192fb79dc95a57672367997091df48454211605c428cc5c127280178", + "Type": "Image", + "length": 2, + "content": null +} diff --git a/docs/validation/evidence/General-Sending-20260906/send-multiline.json b/docs/validation/evidence/General-Sending-20260906/send-multiline.json new file mode 100644 index 0000000..5d0f6ce --- /dev/null +++ b/docs/validation/evidence/General-Sending-20260906/send-multiline.json @@ -0,0 +1,6 @@ +{ + "Fingerprint": "3ab05f45aecf440154d308dc4ca647ab760fb8098efd9029a19df735cc8c02e6", + "Type": "Text", + "length": 112, + "content": null +} diff --git a/docs/validation/evidence/General-Sending-20260906/smoke.json b/docs/validation/evidence/General-Sending-20260906/smoke.json new file mode 100644 index 0000000..a9d4eac --- /dev/null +++ b/docs/validation/evidence/General-Sending-20260906/smoke.json @@ -0,0 +1,6 @@ +{ + "doctorErrors": [], + "nodes": 162, + "sanitized": true, + "confirmed": true +} diff --git a/docs/validation/evidence/General-Sending-20260906/summary.json b/docs/validation/evidence/General-Sending-20260906/summary.json new file mode 100644 index 0000000..f256e90 --- /dev/null +++ b/docs/validation/evidence/General-Sending-20260906/summary.json @@ -0,0 +1,43 @@ +{ + "Checks": [ + { + "Stage": "doctor", + "ExitCode": 0 + }, + { + "Stage": "inspect-ui", + "ExitCode": 0 + }, + { + "Stage": "smoke", + "ExitCode": 0 + }, + { + "Stage": "send-multiline", + "ExitCode": 0 + }, + { + "Stage": "send-file", + "ExitCode": 0 + }, + { + "Stage": "send-image", + "ExitCode": 0 + }, + { + "Stage": "reply-latest", + "ExitCode": 0 + }, + { + "Stage": "mention-member", + "ExitCode": 0 + }, + { + "Stage": "mention-all", + "ExitCode": 0 + } + ], + "BinarySha256": "45E9994739D33E0786F3D224E9905DDF7439CCD322E6BAF21B431880706A0229", + "Success": true, + "MentionAttempted": true +} diff --git a/scripts/windows/Test-GeneralSending.ps1 b/scripts/windows/Test-GeneralSending.ps1 new file mode 100644 index 0000000..02d30e4 --- /dev/null +++ b/scripts/windows/Test-GeneralSending.ps1 @@ -0,0 +1,51 @@ +param( + [Parameter(Mandatory=$true)][string]$Executable, + [Parameter(Mandatory=$true)][string]$OutputDirectory, + [ValidateSet('文件传输助手','消息测试专用群组')][string]$Session='消息测试专用群组', + [switch]$IncludeMentions +) +if($IncludeMentions -and $Session -ne '消息测试专用群组'){throw 'Mention validation is restricted to the approved test group.'} +$ErrorActionPreference='Stop' +$exe=(Resolve-Path $Executable).Path +if(Test-Path $OutputDirectory){throw 'Use a fresh evidence directory.'} +$directory=(New-Item -ItemType Directory $OutputDirectory).FullName +$checks=New-Object System.Collections.Generic.List[object] +$marker='wx-agent-general-'+[Guid]::NewGuid().ToString('N') +function Invoke-Check([string]$Stage,[string[]]$Arguments){ + # Record the attempt before execution. Never retry a send, including an unconfirmed result. + @{Stage=$Stage;StartedUtc=[DateTime]::UtcNow.ToString('o')} | ConvertTo-Json | Set-Content "$directory\$Stage-attempt.json" -Encoding UTF8 + $raw=(& $exe @Arguments 2> "$directory\$Stage.stderr.txt" | Out-String) + $code=$LASTEXITCODE + $raw | Set-Content "$directory\$Stage.json" -Encoding UTF8 + $checks.Add(@{Stage=$Stage;ExitCode=$code}) + if($code -ne 0){throw "$Stage failed; no further operations will run."} +} +try { + Invoke-Check 'doctor' @('doctor','--timeout','30') + Invoke-Check 'inspect-ui' @('inspect-ui','--output',"$directory\ui-tree.json",'--timeout','30') + Invoke-Check 'smoke' @('smoke','--timeout','60') + $file=Join-Path $directory "$marker.txt" + [IO.File]::WriteAllText($file,$marker) + Add-Type -AssemblyName System.Drawing + $image=Join-Path $directory "$marker.png" + $bitmap=New-Object Drawing.Bitmap 32,32 + try { $bitmap.SetPixel(16,16,[Drawing.Color]::Blue); $bitmap.Save($image,[Drawing.Imaging.ImageFormat]::Png) } + finally { $bitmap.Dispose() } + Invoke-Check 'send-multiline' @('chat','send','--session',$Session,'--text',"$marker-line1`r`n`r`n$marker-line3",'--timeout','60') + Invoke-Check 'send-file' @('chat','send-file','--session',$Session,'--path',$file,'--timeout','60') + Invoke-Check 'send-image' @('chat','send-image','--session',$Session,'--path',$image,'--timeout','60') + Invoke-Check 'reply-latest' @('chat','reply-latest','--session',$Session,'--text',"$marker-reply1`r`n`r`n$marker-reply3",'--timeout','60') + if($IncludeMentions){ + Invoke-Check 'mention-member' @('chat','mention','--session',$Session,'--member','Hao 豪','--text',"$marker-member",'--confirm','CONFIRM','--timeout','60') + Invoke-Check 'mention-all' @('chat','mention','--session',$Session,'--member','所有人','--text',"$marker-all",'--confirm','CONFIRM','--timeout','60') + } +} catch { + @{Message=$_.Exception.Message;Line=$_.InvocationInfo.ScriptLineNumber} | ConvertTo-Json | Set-Content "$directory\error.json" -Encoding UTF8 +} finally { + $expectedCount=7 + if($IncludeMentions){$expectedCount=9} + $success=($checks.Count -eq $expectedCount -and @($checks | Where-Object {$_.ExitCode -ne 0}).Count -eq 0) + @{Success=$success;Checks=@($checks.ToArray());MentionAttempted=(@($checks | Where-Object {$_.Stage -eq 'mention-member'}).Count -gt 0);BinarySha256=(Get-FileHash $exe -Algorithm SHA256).Hash} | + ConvertTo-Json -Depth 6 | Set-Content "$directory\summary.json" -Encoding UTF8 +} +if(!$success){exit 1} diff --git a/src/WxAgent.Core/ChatMessages.cs b/src/WxAgent.Core/ChatMessages.cs index 5a72c10..c32cab3 100644 --- a/src/WxAgent.Core/ChatMessages.cs +++ b/src/WxAgent.Core/ChatMessages.cs @@ -58,20 +58,22 @@ public static class VisibleMessageParser public static (string Text, QuotedMessageSnapshot? Quote) ParseQuote(string accessibleName) { - var lines = accessibleName.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (lines.Length >= 2 && lines[^1].StartsWith("引用 ", StringComparison.Ordinal)) + var normalized = accessibleName.ReplaceLineEndings("\n"); + var lastLine = normalized.LastIndexOf('\n'); + if (lastLine >= 0 && normalized[(lastLine + 1)..].StartsWith("引用 ", StringComparison.Ordinal)) { - var descriptor = lines[^1]; + var descriptor = normalized[(lastLine + 1)..]; var messageMarker = descriptor.IndexOf(" 的消息", StringComparison.Ordinal); var separator = descriptor.IndexOfAny([':', ':']); if (messageMarker > 3 && separator > messageMarker) { var sender = descriptor[3..messageMarker].Trim(); var quoteText = descriptor[(separator + 1)..].Trim(); - return (string.Join('\n', lines[..^1]), new QuotedMessageSnapshot(sender, quoteText)); + return (normalized[..lastLine], new QuotedMessageSnapshot(sender, quoteText)); } } + var lines = accessibleName.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var quoteIndex = Array.FindIndex(lines, line => string.Equals(line, "引用", StringComparison.Ordinal) || line.StartsWith("引用:", StringComparison.Ordinal)); if (quoteIndex < 0 || lines.Length - quoteIndex < 3) diff --git a/src/WxAgent.Core/WechatLocators.cs b/src/WxAgent.Core/WechatLocators.cs index 3468e7e..149491d 100644 --- a/src/WxAgent.Core/WechatLocators.cs +++ b/src/WxAgent.Core/WechatLocators.cs @@ -23,6 +23,8 @@ public static class WechatLocators public const string ChatPage = "chat_message_page"; public const string MessageList = "chat_message_list"; public const string ChatInput = "chat_input_field"; + public const string MentionPopover = "MentionPopover"; + public const string MentionList = "chat_mention_list"; public const string ToolBar = "tool_bar_accessible"; public const string CurrentChatName = "content_view.top_content_view.title_h_view.left_v_view.left_content_v_view.left_ui_.big_title_line_h_view.current_chat_name_label"; public const string ChatBubbleItem = "chat_message_list.qt_scrollarea_viewport.chat_bubble_item_view"; diff --git a/src/WxAgent.Core/WechatSendConfirmation.cs b/src/WxAgent.Core/WechatSendConfirmation.cs new file mode 100644 index 0000000..ee350c1 --- /dev/null +++ b/src/WxAgent.Core/WechatSendConfirmation.cs @@ -0,0 +1,28 @@ +namespace WxAgent.Core; + +public static class WechatSendConfirmation +{ + public static bool IsMentionToken(string text) => text.TrimEnd() == "\uFFFC"; + + public static bool MatchesMention(ChatMessageSnapshot message, IReadOnlySet baseline, + string memberName, string? body) + { + if (baseline.Contains(message.Fingerprint) || message.Type != ChatMessageType.Text) return false; + var expected = "@" + memberName + (string.IsNullOrEmpty(body) ? "" : " " + body); + return string.Equals(NormalizeWhitespace(message.Text), NormalizeWhitespace(expected), StringComparison.Ordinal); + } + + private static string NormalizeWhitespace(string text) => + string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + // UIA proves only a fresh matching visible row, not delivery or sender identity. + public static bool Matches(ChatMessageSnapshot message, IReadOnlySet baseline, + ChatMessageType type, string? expectedText) + { + if (baseline.Contains(message.Fingerprint) || 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)) + : string.Equals(message.Text, expectedText, StringComparison.Ordinal); + } +} diff --git a/src/WxAgent.Core/WechatTextInput.cs b/src/WxAgent.Core/WechatTextInput.cs new file mode 100644 index 0000000..b91cc4e --- /dev/null +++ b/src/WxAgent.Core/WechatTextInput.cs @@ -0,0 +1,14 @@ +namespace WxAgent.Core; + +public static class WechatTextInput +{ + public static string Prepare(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."); + return normalized; + } +} diff --git a/src/WxAgent.Host/Program.cs b/src/WxAgent.Host/Program.cs index 349caf8..ae21d68 100644 --- a/src/WxAgent.Host/Program.cs +++ b/src/WxAgent.Host/Program.cs @@ -279,21 +279,32 @@ try } case "chat" when args[1] == "reply-latest": { - var message = await WechatChatClient.ReplyToLatestAsync(GetRequiredOption(args, "--text"), cancellationToken); + var message = await WechatChatClient.ReplyToLatestAsync(GetRequiredOption(args, "--text"), cancellationToken, GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant); WriteJson(ToMessageOutput(message, includeContent: false)); return 0; } + case "chat" when args[1] == "mention": + { + var session = GetRequiredOption(args, "--session"); + var member = GetRequiredOption(args, "--member"); + var text = GetOption(args, "--text"); + var confirmation = GetRequiredOption(args, "--confirm"); + var result = await WechatChatClient.MentionMemberAsync(session, member, text, confirmation, cancellationToken); + WriteJson(result); + return result.Success ? 0 : 2; + } case "chat" when args[1] is "send-file" or "send-image": { var path = GetRequiredOption(args, "--path"); + var session = GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant; if (args[1] == "send-image" && !IsImagePath(path)) { throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "send-image accepts png, jpg, jpeg, gif, bmp, or webp files."); } var message = args[1] == "send-image" - ? await WechatChatClient.SendImageAsync(path, cancellationToken) - : await WechatChatClient.SendFileAsync(path, cancellationToken); + ? await WechatChatClient.SendImageAsync(path, cancellationToken, session) + : await WechatChatClient.SendFileAsync(path, cancellationToken, session); WriteJson(ToMessageOutput(message, includeContent: false)); return 0; } @@ -669,11 +680,14 @@ static int ValidateCommandLine(string[] values) ValidateOptions(values, 2, ["--text", "--session", "--timeout"], []); return 30; case "reply-latest": - ValidateOptions(values, 2, ["--text", "--timeout"], []); + ValidateOptions(values, 2, ["--text", "--session", "--timeout"], []); return 60; case "send-file": case "send-image": - ValidateOptions(values, 2, ["--path", "--timeout"], []); + ValidateOptions(values, 2, ["--path", "--session", "--timeout"], []); + return 60; + case "mention": + ValidateOptions(values, 2, ["--session", "--member", "--text", "--confirm", "--timeout"], []); return 60; case "read": ValidateOptions(values, 2, ["--limit", "--timeout"], ["--include-content"]); @@ -863,10 +877,11 @@ WxAgent.Host commands: group verify-at-all --group --message [--timeout 30] chat send-url-card --to --url [--message ] --confirm CONFIRM [--timeout 60] chat send-audio --to --path --confirm CONFIRM [--timeout 60] - chat send --text [--session ] [--timeout 30] - chat reply-latest --text [--timeout 60] - chat send-file --path [--timeout 60] - chat send-image --path [--timeout 60] + chat send --text [--session ] [--timeout 30] + chat reply-latest --text [--session ] [--timeout 60] + chat send-file --path [--session ] [--timeout 60] + chat send-image --path [--session ] [--timeout 60] + chat mention --session --member --text --confirm CONFIRM [--timeout 60] chat read [--limit 20] [--include-content] [--timeout 30] chat history [--limit 100] [--scrolls 10] [--include-content] [--timeout 60] chat monitor [--seconds 60] [--session ] [--independent] [--state-file ] [--include-content] [--timeout ] diff --git a/src/WxAgent.Windows/WechatChatClient.M5.cs b/src/WxAgent.Windows/WechatChatClient.M5.cs index d61c77f..9d2b6c9 100644 --- a/src/WxAgent.Windows/WechatChatClient.M5.cs +++ b/src/WxAgent.Windows/WechatChatClient.M5.cs @@ -107,7 +107,7 @@ public static partial class WechatChatClient if (!string.IsNullOrWhiteSpace(text)) SetFirstEdit(moments, text); foreach (var path in paths) { - using (new ClipboardFileLease(path, IsImageFile(path))) + using (new ClipboardLease(path, IsImageFile(path))) { Keyboard.TypeSimultaneously([VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_V]); await Task.Delay(250, cancellationToken).ConfigureAwait(false); diff --git a/src/WxAgent.Windows/WechatChatClient.Management.cs b/src/WxAgent.Windows/WechatChatClient.Management.cs index 78ff085..f0c9d6f 100644 --- a/src/WxAgent.Windows/WechatChatClient.Management.cs +++ b/src/WxAgent.Windows/WechatChatClient.Management.cs @@ -282,27 +282,73 @@ public static partial class WechatChatClient }, cancellationToken); public static Task AtAllAsync(string group, string? message, string confirmation, CancellationToken cancellationToken = default) => - WithConfirmedMainWindowAsync("mention all", confirmation, async (main, automation) => + MentionMemberAsync(group, "所有人", message, confirmation, cancellationToken); + + public static Task MentionMemberAsync(string session, string memberName, string? message, string confirmation, CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("mention member", confirmation, async (main, automation) => { - await OpenNamedSessionAsync(main, group, cancellationToken).ConfigureAwait(false); + ArgumentException.ThrowIfNullOrWhiteSpace(session); + ArgumentException.ThrowIfNullOrWhiteSpace(memberName); + 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."); + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); var input = FindByAutomationId(main, WechatLocators.ChatInput) ?? throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Chat input was not found."); - input.Click(); - Keyboard.TypeSimultaneously([VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_A]); - Keyboard.Press(VirtualKeyShort.BACK); - Keyboard.Type("@"); - await Task.Delay(300, cancellationToken).ConfigureAwait(false); - var everyone = automation.GetDesktop().FindAllDescendants(cf => cf.ByName("所有人")) - .FirstOrDefault(element => !element.Properties.IsOffscreen.ValueOrDefault && !element.BoundingRectangle.IsEmpty) - ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The WeChat '所有人' mention option was not found."); - everyone.Click(); + if (!IsListeningSession(main, session, independent: false)) + 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); + 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."); + ClickCenter(input); + ExecuteInputStep("open-mention", () => Keyboard.Type("@")); + await Task.Delay(400, cancellationToken).ConfigureAwait(false); + // Observed on 4.1.13.63: the popover is a child of the bound main window. + var popovers = main.FindAllDescendants(cf => cf.ByAutomationId(WechatLocators.MentionPopover)) + .Where(element => !element.Properties.IsOffscreen.ValueOrDefault && !element.BoundingRectangle.IsEmpty).ToArray(); + if (popovers.Length != 1) + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "A unique mention popup was not found; no send was attempted."); + 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."); + if (!IsListeningSession(main, session, independent: false)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before mention selection."); + ClickCenter(options[0]); await Task.Delay(100, cancellationToken).ConfigureAwait(false); + input = FindByAutomationId(main, WechatLocators.ChatInput) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Chat input disappeared after mention selection."); + var mentionToken = input.AsTextBox().Text; + if (!WechatSendConfirmation.IsMentionToken(mentionToken)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The selected mention was not confirmed in the input; no send was attempted."); if (!string.IsNullOrEmpty(message)) { - Keyboard.Type(message); + if (!IsListeningSession(main, session, independent: false)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before mention text input."); + // Paste preserves the selected mention token and avoids IME conversion of typed Latin text. + using var clipboard = new ClipboardLease(" " + message, asText: true); + ExecuteInputStep("mention-text", () => Keyboard.TypeSimultaneously([VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_V])); + await Task.Delay(300, cancellationToken).ConfigureAwait(false); } - Keyboard.Press(VirtualKeyShort.ENTER); - return WechatOperationResult.Ok("message sent"); + var text = input.AsTextBox().Text; + if (!IsListeningSession(main, session, independent: false) || + !string.Equals(text, mentionToken + (string.IsNullOrEmpty(message) ? "" : " " + message), StringComparison.Ordinal)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The mention input or conversation changed; no send was attempted."); + var send = main.FindAllDescendants().FirstOrDefault(element => + SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found."); + ClickCenter(send); + var confirmed = await WaitForNewMessageAsync(baseline, session, ChatMessageType.Text, message, + TimeSpan.FromSeconds(20), cancellationToken, memberName); + return confirmed is not null ? WechatOperationResult.Ok("visible message confirmed") + : throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "No matching visible mention confirmed the send; no retry was attempted."); }, cancellationToken); public static Task SelectSessionOptionAsync(string session, string option, string? confirmation = null, CancellationToken cancellationToken = default) => diff --git a/src/WxAgent.Windows/WechatChatClient.cs b/src/WxAgent.Windows/WechatChatClient.cs index 7e47f35..4ce29d2 100644 --- a/src/WxAgent.Windows/WechatChatClient.cs +++ b/src/WxAgent.Windows/WechatChatClient.cs @@ -125,12 +125,8 @@ public static partial class WechatChatClient public static async Task SendTextAsync(string text, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) { - ArgumentException.ThrowIfNullOrWhiteSpace(text); + text = WechatTextInput.Prepare(text); ArgumentException.ThrowIfNullOrWhiteSpace(session); - if (text.Length > 4000 || text.Contains('\r') || text.Contains('\n')) - { - throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "M1 text messages must be one line and no longer than 4000 characters."); - } await CommandQueue.WaitAsync(cancellationToken); try @@ -150,7 +146,7 @@ public static partial class WechatChatClient 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, text, StringComparison.Ordinal)) + if (!IsListeningSession(window, session, independent: false) || !string.Equals(inputBox.Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal)) { throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The chat input did not contain the exact requested text; send was aborted."); } @@ -170,23 +166,29 @@ public static partial class WechatChatClient } } - public static async Task ReplyToLatestAsync(string text, CancellationToken cancellationToken) + public static async Task ReplyToLatestAsync(string text, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) { - ArgumentException.ThrowIfNullOrWhiteSpace(text); - if (text.Length > 4000 || text.Contains('\r') || text.Contains('\n')) - { - throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Reply text must be one line and no longer than 4000 characters."); - } + text = WechatTextInput.Prepare(text); + ArgumentException.ThrowIfNullOrWhiteSpace(session); await CommandQueue.WaitAsync(cancellationToken); try { using var automation = new UIA3Automation(); var window = AttachWindow(automation, cancellationToken); - await OpenFileTransferAssistantCoreAsync(window, 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 quoting."); + } var before = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal); var list = FindByAutomationId(window, WechatLocators.MessageList) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found."); + var draft = FindByAutomationId(window, WechatLocators.ChatInput) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Chat input was not found."); + if (!string.IsNullOrEmpty(draft.AsTextBox().Text)) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The conversation contains an existing draft; it was not overwritten."); var latest = list.FindAllDescendants() .LastOrDefault(element => SafeAutomationId(element) == WechatLocators.ChatBubbleItem) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "No visible message is available to quote."); @@ -211,8 +213,12 @@ public static partial class WechatChatClient window = WechatDoctor.FindWechatWindow(automation) ?? window; var input = FindByAutomationId(window, WechatLocators.ChatInput) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found."); - input.AsTextBox().Text = text; - if (!string.Equals(input.AsTextBox().Text, text, StringComparison.Ordinal)) + if (!IsListeningSession(window, session, independent: false)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before reply input."); + if (!string.IsNullOrEmpty(input.AsTextBox().Text)) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The reply input is not empty; it was not overwritten."); + ExecuteInputStep("set-reply-value", () => input.AsTextBox().Text = text); + if (!IsListeningSession(window, session, independent: false) || !string.Equals(input.AsTextBox().Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal)) { throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The reply input did not contain the exact requested text; send was aborted."); } @@ -221,7 +227,7 @@ public static partial class WechatChatClient SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found."); ClickCenter(send); - var confirmed = await WaitForNewMessageAsync(before, TimeSpan.FromSeconds(30), cancellationToken); + var confirmed = await WaitForNewMessageAsync(before, session, ChatMessageType.Quote, text, TimeSpan.FromSeconds(30), cancellationToken); return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "No new visible quoted reply confirmed the send result."); } finally @@ -230,14 +236,15 @@ public static partial class WechatChatClient } } - public static Task SendFileAsync(string path, CancellationToken cancellationToken) => - SendAttachmentAsync(path, asImage: false, cancellationToken); + public static Task SendFileAsync(string path, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) => + SendAttachmentAsync(path, asImage: false, session, cancellationToken); - public static Task SendImageAsync(string path, CancellationToken cancellationToken) => - SendAttachmentAsync(path, asImage: true, cancellationToken); + public static Task SendImageAsync(string path, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) => + SendAttachmentAsync(path, asImage: true, session, cancellationToken); - private static async Task SendAttachmentAsync(string path, bool asImage, CancellationToken cancellationToken) + private static async Task SendAttachmentAsync(string path, bool asImage, string session, CancellationToken cancellationToken) { + ArgumentException.ThrowIfNullOrWhiteSpace(session); var fullPath = Path.GetFullPath(path); if (!File.Exists(fullPath)) { @@ -249,33 +256,43 @@ public static partial class WechatChatClient { using var automation = new UIA3Automation(); var window = AttachWindow(automation, cancellationToken); - await OpenFileTransferAssistantCoreAsync(window, cancellationToken); + await OpenNamedSessionAsync(window, session, cancellationToken).ConfigureAwait(false); + await Task.Delay(500, cancellationToken); + if (!IsListeningSession(window, session, independent: false)) + { + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested conversation changed before attachment input."); + } var before = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal); var input = FindByAutomationId(window, WechatLocators.ChatInput) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found."); - var inputText = input.AsTextBox().Text?.Trim(); - var placeholder = SafeName(input).Trim(); - if (!string.IsNullOrEmpty(inputText) && !string.Equals(inputText, placeholder, StringComparison.Ordinal)) + var inputText = input.AsTextBox().Text; + if (!string.IsNullOrEmpty(inputText)) { throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, $"The chat input contains an unsent draft ({inputText.Length} characters); attachment send was aborted."); } ClickCenter(input); await Task.Delay(150, cancellationToken); - using (new ClipboardFileLease(fullPath, asImage)) + using (new ClipboardLease(fullPath, asImage)) { - Keyboard.TypeSimultaneously([VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_V]); + if (!IsListeningSession(window, session, independent: false)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before attachment paste."); + ExecuteInputStep("paste-attachment", () => Keyboard.TypeSimultaneously([VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_V])); await Task.Delay(500, cancellationToken); } var refreshedWindow = WechatDoctor.FindWechatWindow(automation) ?? throw new WxAgentException(WxAgentErrorCode.WindowNotFound, "WeChat main window disappeared after attachment paste."); + if (!IsListeningSession(refreshedWindow, session, independent: false)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before attachment send."); 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, TimeSpan.FromSeconds(30), cancellationToken); - return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The attachment selection completed but no new visible message confirmed the send result."); + 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."); } finally { @@ -654,9 +671,13 @@ public static partial class WechatChatClient } private static async Task WaitForNewMessageAsync( - HashSet previous, + IReadOnlySet previous, + string session, + ChatMessageType type, + string? expectedText, TimeSpan timeout, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string? mentionMember = null) { var deadline = DateTimeOffset.UtcNow + timeout; while (DateTimeOffset.UtcNow < deadline) @@ -664,7 +685,12 @@ public static partial class WechatChatClient cancellationToken.ThrowIfCancellationRequested(); using var automation = new UIA3Automation(); var window = WechatDoctor.FindWechatWindow(automation); - var message = window is null ? null : ReadVisible(window).LastOrDefault(item => !previous.Contains(item.Fingerprint)); + 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 => + mentionMember is null + ? WechatSendConfirmation.Matches(item, previous, type, expectedText) + : WechatSendConfirmation.MatchesMention(item, previous, mentionMember, expectedText)); if (message is not null) return message; await Task.Delay(250, cancellationToken); } @@ -690,7 +716,7 @@ public static partial class WechatChatClient var match = window is null ? null : ReadVisible(window).LastOrDefault(message => - string.Equals(message.Text, text, StringComparison.Ordinal) && !baseline.Contains(message.Fingerprint)); + string.Equals(message.Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal) && !baseline.Contains(message.Fingerprint)); if (match is not null) { return match; @@ -714,16 +740,16 @@ public static partial class WechatChatClient private static AutomationElement? FindByAutomationId(AutomationElement root, string automationId) => root.FindFirstDescendant(condition => condition.ByAutomationId(automationId)); - private sealed class ClipboardFileLease : IDisposable + private sealed class ClipboardLease : IDisposable { private readonly ManualResetEventSlim _ready = new(); private readonly ManualResetEventSlim _restore = new(); private readonly Thread _thread; private Exception? _error; - public ClipboardFileLease(string fullPath, bool asImage) + public ClipboardLease(string content, bool asImage = false, bool asText = false) { - _thread = new Thread(() => Run(fullPath, asImage)) { IsBackground = true }; + _thread = new Thread(() => Run(content, asImage, asText)) { IsBackground = true }; _thread.SetApartmentState(ApartmentState.STA); _thread.Start(); if (!_ready.Wait(TimeSpan.FromSeconds(5))) @@ -745,7 +771,7 @@ public static partial class WechatChatClient _restore.Dispose(); } - private void Run(string fullPath, bool asImage) + private void Run(string content, bool asImage, bool asText) { string? text = null; StringCollection? files = null; @@ -756,15 +782,19 @@ public static partial class WechatChatClient if (Clipboard.ContainsFileDropList()) files = Clipboard.GetFileDropList(); else if (Clipboard.ContainsImage()) image = (System.Drawing.Image?)Clipboard.GetImage()?.Clone(); else if (Clipboard.ContainsText()) text = Clipboard.GetText(); - if (asImage) + if (asText) { - using var source = System.Drawing.Image.FromFile(fullPath); + Clipboard.SetText(content); + } + else if (asImage) + { + using var source = System.Drawing.Image.FromFile(content); outgoingImage = new System.Drawing.Bitmap(source); Clipboard.SetImage(outgoingImage); } else { - Clipboard.SetFileDropList(new StringCollection { fullPath }); + Clipboard.SetFileDropList(new StringCollection { content }); } } catch (Exception exception) @@ -798,7 +828,7 @@ public static partial class WechatChatClient { if (_error is not null) { - throw new WxAgentException(WxAgentErrorCode.ClipboardUnavailable, "The Windows clipboard could not be used for attachment sending.", _error); + throw new WxAgentException(WxAgentErrorCode.ClipboardUnavailable, "The Windows clipboard could not be used for input.", _error); } } } diff --git a/tests/WxAgent.Core.Tests/WechatMentionConfirmationTests.cs b/tests/WxAgent.Core.Tests/WechatMentionConfirmationTests.cs new file mode 100644 index 0000000..6d17694 --- /dev/null +++ b/tests/WxAgent.Core.Tests/WechatMentionConfirmationTests.cs @@ -0,0 +1,31 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatMentionConfirmationTests +{ + [Theory] + [InlineData("\uFFFC", true)] + [InlineData("\uFFFC\u2005", true)] + [InlineData("@Hao 豪", false)] + [InlineData("@", false)] + [InlineData("\uFFFCunexpected", false)] + [InlineData("\uFFFC\uFFFC", false)] + public void RequiresExactlyOneStructuredMentionToken(string value, bool expected) => + Assert.Equal(expected, WechatSendConfirmation.IsMentionToken(value)); + + [Theory] + [InlineData("@Hao 豪 marker", "Hao 豪", true)] + [InlineData("@Hao 豪\u2005marker", "Hao 豪", true)] + [InlineData("@所有人 marker", "所有人", true)] + [InlineData("@Hao 豪 other", "Hao 豪", false)] + [InlineData("@Other marker", "Hao 豪", false)] + public void RequiresFreshExactMentionAndBody(string text, string member, bool expected) + { + var message = new ChatMessageSnapshot(text, "fresh", 0, ChatMessageType.Text); + Assert.Equal(expected, WechatSendConfirmation.MatchesMention(message, new HashSet(), member, "marker")); + Assert.False(WechatSendConfirmation.MatchesMention(message, new HashSet { "fresh" }, member, "marker")); + Assert.False(WechatSendConfirmation.MatchesMention(message with { Type = ChatMessageType.Quote }, new HashSet(), member, "marker")); + } +} diff --git a/tests/WxAgent.Core.Tests/WechatSendConfirmationTests.cs b/tests/WxAgent.Core.Tests/WechatSendConfirmationTests.cs new file mode 100644 index 0000000..a198d9f --- /dev/null +++ b/tests/WxAgent.Core.Tests/WechatSendConfirmationTests.cs @@ -0,0 +1,39 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatSendConfirmationTests +{ + [Fact] + public void RequiresFreshMatchingTypeAndExactReplyText() + { + var baseline = new HashSet { "old" }; + 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")); + } + + [Theory] + [InlineData("report.txt", true)] + [InlineData("文件\nreport.txt\n1 KB", true)] + [InlineData("文件\r\nreport.txt\r\n1 KB", true)] + [InlineData("other-report.txt", false)] + [InlineData("report.txt.backup", false)] + public void FileConfirmationRequiresExactFilenameLine(string text, bool expected) + { + var message = new ChatMessageSnapshot(text, "new", 0, ChatMessageType.File); + Assert.Equal(expected, WechatSendConfirmation.Matches(message, new HashSet(), ChatMessageType.File, "report.txt")); + } + + [Fact] + public void ImageConfirmationRejectsOtherTypesAndOldRows() + { + var message = new ChatMessageSnapshot("[图片]", "new", 0, ChatMessageType.Image); + Assert.True(WechatSendConfirmation.Matches(message, new HashSet(), ChatMessageType.Image, null)); + Assert.False(WechatSendConfirmation.Matches(message, new HashSet { "new" }, ChatMessageType.Image, null)); + Assert.False(WechatSendConfirmation.Matches(message with { Type = ChatMessageType.Text }, new HashSet(), ChatMessageType.Image, null)); + } +} diff --git a/tests/WxAgent.Core.Tests/WechatTextInputTests.cs b/tests/WxAgent.Core.Tests/WechatTextInputTests.cs new file mode 100644 index 0000000..1228adc --- /dev/null +++ b/tests/WxAgent.Core.Tests/WechatTextInputTests.cs @@ -0,0 +1,34 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatTextInputTests +{ + [Fact] + public void PreservesMultilineWhitespaceWithCanonicalLineEndings() + { + Assert.Equal("first\n\n second\t\nthird", WechatTextInput.Prepare("first\r\n\r\n second\t\rthird")); + Assert.Equal(new string('x', 4000), WechatTextInput.Prepare(new string('x', 4000))); + Assert.Throws(() => WechatTextInput.Prepare(new string('x', 4001))); + } + + [Theory] + [InlineData("")] + [InlineData(" \r\n\t")] + [InlineData("before\0after")] + [InlineData("before\u001bafter")] + public void RejectsEmptyOrUnsafeInput(string input) => + Assert.Throws(() => WechatTextInput.Prepare(input)); + + [Fact] + public void QuoteParserPreservesBlankLinesAndIndentation() + { + const string reply = "first\n\n second "; + var parsed = Assert.Single(VisibleMessageParser.Parse([reply + "\n引用 Alice 的消息 : original"])); + Assert.Equal(reply, parsed.Text); + Assert.Equal(ChatMessageType.Quote, parsed.Type); + Assert.True(WechatSendConfirmation.Matches(parsed, new HashSet(), ChatMessageType.Quote, reply)); + Assert.Equal("original", parsed.Quote!.Text); + } +}