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.
This commit is contained in:
@@ -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 卡片仍未在本增量实现或验收。
|
||||
- 导航补齐和合并聊天记录展开尚未由本提交实现;不能把本记录视为四项需求全部完成。
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"CurrentSessionId": 1,
|
||||
"InputDesktopAvailable": true,
|
||||
"WindowFound": true,
|
||||
"Errors": []
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Success": true,
|
||||
"Code": null,
|
||||
"Message": "visible message confirmed"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Success": true,
|
||||
"Code": null,
|
||||
"Message": "visible message confirmed"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"Fingerprint": "b787bffca17193219159d001a2cdd7d70d82aaa8628dffa0135173af205f698d",
|
||||
"Type": "Quote",
|
||||
"length": 114,
|
||||
"content": null
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"Fingerprint": "dcd1b8ceaef77ca58f0656abdf7dce58aadcf997a991d5bb94a88dc3be8d7042",
|
||||
"Type": "File",
|
||||
"length": 77,
|
||||
"content": null
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"Fingerprint": "8d879245192fb79dc95a57672367997091df48454211605c428cc5c127280178",
|
||||
"Type": "Image",
|
||||
"length": 2,
|
||||
"content": null
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"Fingerprint": "3ab05f45aecf440154d308dc4ca647ab760fb8098efd9029a19df735cc8c02e6",
|
||||
"Type": "Text",
|
||||
"length": 112,
|
||||
"content": null
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"doctorErrors": [],
|
||||
"nodes": 162,
|
||||
"sanitized": true,
|
||||
"confirmed": true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string> 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<string> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 <name> --message <text> [--timeout 30]
|
||||
chat send-url-card --to <name> --url <http-url> [--message <text>] --confirm CONFIRM [--timeout 60]
|
||||
chat send-audio --to <name> --path <audio-file> --confirm CONFIRM [--timeout 60]
|
||||
chat send --text <one-line-text> [--session <name>] [--timeout 30]
|
||||
chat reply-latest --text <one-line-text> [--timeout 60]
|
||||
chat send-file --path <file> [--timeout 60]
|
||||
chat send-image --path <image> [--timeout 60]
|
||||
chat send --text <text-up-to-4000-chars; multiline-supported> [--session <name>] [--timeout 30]
|
||||
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 mention --session <name> --member <display-name> --text <text> --confirm CONFIRM [--timeout 60]
|
||||
chat read [--limit 20] [--include-content] [--timeout 30]
|
||||
chat history [--limit 100] [--scrolls 10] [--include-content] [--timeout 60]
|
||||
chat monitor [--seconds 60] [--session <name>] [--independent] [--state-file <path>] [--include-content] [--timeout <seconds>]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -282,27 +282,73 @@ public static partial class WechatChatClient
|
||||
}, cancellationToken);
|
||||
|
||||
public static Task<WechatOperationResult> 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<WechatOperationResult> 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<WechatOperationResult> SelectSessionOptionAsync(string session, string option, string? confirmation = null, CancellationToken cancellationToken = default) =>
|
||||
|
||||
@@ -125,12 +125,8 @@ public static partial class WechatChatClient
|
||||
public static async Task<ChatMessageSnapshot> 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<ChatMessageSnapshot> ReplyToLatestAsync(string text, CancellationToken cancellationToken)
|
||||
public static async Task<ChatMessageSnapshot> 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<ChatMessageSnapshot> SendFileAsync(string path, CancellationToken cancellationToken) =>
|
||||
SendAttachmentAsync(path, asImage: false, cancellationToken);
|
||||
public static Task<ChatMessageSnapshot> SendFileAsync(string path, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) =>
|
||||
SendAttachmentAsync(path, asImage: false, session, cancellationToken);
|
||||
|
||||
public static Task<ChatMessageSnapshot> SendImageAsync(string path, CancellationToken cancellationToken) =>
|
||||
SendAttachmentAsync(path, asImage: true, cancellationToken);
|
||||
public static Task<ChatMessageSnapshot> SendImageAsync(string path, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) =>
|
||||
SendAttachmentAsync(path, asImage: true, session, cancellationToken);
|
||||
|
||||
private static async Task<ChatMessageSnapshot> SendAttachmentAsync(string path, bool asImage, CancellationToken cancellationToken)
|
||||
private static async Task<ChatMessageSnapshot> 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<ChatMessageSnapshot?> WaitForNewMessageAsync(
|
||||
HashSet<string> previous,
|
||||
IReadOnlySet<string> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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"));
|
||||
}
|
||||
}
|
||||
@@ -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<string> { "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<string>(), 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));
|
||||
}
|
||||
}
|
||||
@@ -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<WxAgentException>(() => 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<WxAgentException>(() => 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<string>(), ChatMessageType.Quote, reply));
|
||||
Assert.Equal("original", parsed.Quote!.Text);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user