From 5e3aa97644370a94040c01acf65fd5a416d7eda0 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 4 Sep 2026 21:46:38 +0800 Subject: [PATCH] Initial import --- .gitattributes | 5 + .gitignore | 19 + AGENTS.md | 121 +++ README.md | 87 ++ WxAgent.sln | 50 + docs/WxAgent-CSharp-开发计划.md | 646 +++++++++++++ docs/validation/M1-2026-09-04.md | 38 + docs/validation/M1A-2026-09-04.md | 32 + .../M2-Search-Quote-History-2026-09-04.md | 66 ++ docs/validation/M3-Listening-2026-09-04.md | 99 ++ docs/微信数据库密钥获取流程.md | 353 +++++++ src/WxAgent.Core/ChatMessages.cs | 142 +++ src/WxAgent.Core/DatabaseModels.cs | 66 ++ src/WxAgent.Core/MessageEvents.cs | 113 +++ src/WxAgent.Core/SqlCipherPageVerifier.cs | 68 ++ src/WxAgent.Core/UiNodeSnapshot.cs | 44 + src/WxAgent.Core/WcdbKeyCandidateScanner.cs | 84 ++ src/WxAgent.Core/WechatLocators.cs | 37 + src/WxAgent.Core/WechatSessions.cs | 43 + src/WxAgent.Core/WxAgent.Core.csproj | 7 + src/WxAgent.Core/WxAgentErrorCode.cs | 29 + src/WxAgent.Host/Program.cs | 469 +++++++++ src/WxAgent.Host/WxAgent.Host.csproj | 19 + src/WxAgent.Windows/DatabaseKeyStore.cs | 79 ++ .../ListenerCheckpointStore.cs | 52 + src/WxAgent.Windows/NativeMethods.txt | 5 + src/WxAgent.Windows/ProcessMemoryScanner.cs | 158 ++++ .../SqlCipherDatabaseReader.cs | 118 +++ src/WxAgent.Windows/WechatChatClient.cs | 889 ++++++++++++++++++ .../WechatDatabaseDiscovery.cs | 58 ++ src/WxAgent.Windows/WechatDatabaseScanner.cs | 91 ++ src/WxAgent.Windows/WechatDoctor.cs | 113 +++ src/WxAgent.Windows/WechatUiInspector.cs | 75 ++ src/WxAgent.Windows/WxAgent.Windows.csproj | 20 + tests/WxAgent.Core.Tests/CoreTests.cs | 295 ++++++ .../WxAgent.Core.Tests.csproj | 18 + 36 files changed, 4608 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 WxAgent.sln create mode 100644 docs/WxAgent-CSharp-开发计划.md create mode 100644 docs/validation/M1-2026-09-04.md create mode 100644 docs/validation/M1A-2026-09-04.md create mode 100644 docs/validation/M2-Search-Quote-History-2026-09-04.md create mode 100644 docs/validation/M3-Listening-2026-09-04.md create mode 100644 docs/微信数据库密钥获取流程.md create mode 100644 src/WxAgent.Core/ChatMessages.cs create mode 100644 src/WxAgent.Core/DatabaseModels.cs create mode 100644 src/WxAgent.Core/MessageEvents.cs create mode 100644 src/WxAgent.Core/SqlCipherPageVerifier.cs create mode 100644 src/WxAgent.Core/UiNodeSnapshot.cs create mode 100644 src/WxAgent.Core/WcdbKeyCandidateScanner.cs create mode 100644 src/WxAgent.Core/WechatLocators.cs create mode 100644 src/WxAgent.Core/WechatSessions.cs create mode 100644 src/WxAgent.Core/WxAgent.Core.csproj create mode 100644 src/WxAgent.Core/WxAgentErrorCode.cs create mode 100644 src/WxAgent.Host/Program.cs create mode 100644 src/WxAgent.Host/WxAgent.Host.csproj create mode 100644 src/WxAgent.Windows/DatabaseKeyStore.cs create mode 100644 src/WxAgent.Windows/ListenerCheckpointStore.cs create mode 100644 src/WxAgent.Windows/NativeMethods.txt create mode 100644 src/WxAgent.Windows/ProcessMemoryScanner.cs create mode 100644 src/WxAgent.Windows/SqlCipherDatabaseReader.cs create mode 100644 src/WxAgent.Windows/WechatChatClient.cs create mode 100644 src/WxAgent.Windows/WechatDatabaseDiscovery.cs create mode 100644 src/WxAgent.Windows/WechatDatabaseScanner.cs create mode 100644 src/WxAgent.Windows/WechatDoctor.cs create mode 100644 src/WxAgent.Windows/WechatUiInspector.cs create mode 100644 src/WxAgent.Windows/WxAgent.Windows.csproj create mode 100644 tests/WxAgent.Core.Tests/CoreTests.cs create mode 100644 tests/WxAgent.Core.Tests/WxAgent.Core.Tests.csproj diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9751321 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto +*.sln text eol=crlf +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8cef93b --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +**/bin/ +**/obj/ +artifacts/ +TestResults/ +coverage/ +coverage.* + +.vs/ +.vscode/ +.idea/ +.codegraph/ +.mcp.json + +*.user +*.suo +*.log +*.tmp +.DS_Store +Thumbs.db diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9227807 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,121 @@ +# AGENTS.md + +## 项目目标 + +- 使用 C#/.NET 独立实现 wxautox4 商业版的业务功能,只兼容功能,不兼容其 Python API。 +- 当前阶段实现 Desktop Agent、CLI 和业务层,不实现 Windows Service、正式 Web/UI、授权和自动升级。 +- 仅通过 Windows UI Automation、Win32 和正常桌面交互实现;允许为本地数据库只读访问而读取 `Weixin.exe` 内存并进行 SQLCipher 解密;禁止协议破解、DLL 注入、进程内存修改、数据库写入、登录或风控绕过。 +- 开发前先阅读 `docs/WxAgent-CSharp-开发计划.md`,功能范围、里程碑和参考对象以该文档为准。 + +## 技术约束 + +- 使用 .NET 8;Windows 项目目标为 `net8.0-windows10.0.19041.0`、`win-x64`。 +- UI 自动化优先使用 FlaUI.UIA3;只有 FlaUI 明确缺失能力时才直接调用 UIA COM。 +- Win32 API 优先通过 Microsoft.Windows.CsWin32 按需生成,不维护大批手写 P/Invoke。 +- JSON 使用 `System.Text.Json`,日志使用 `Microsoft.Extensions.Logging`,并发使用 `Channel`、`Task` 和 `IAsyncEnumerable`。 +- 数据库能力只读:候选密钥必须通过目标数据库 page 1 HMAC 校验后才能保存或使用;日志和普通 CLI 输出不得包含完整密钥。 +- 不提前引入微服务、插件系统、消息队列、Redis 或新的 UI 框架。 + +## 项目边界 + +```text +src/WxAgent.Core net8.0:模型、解析、去重、状态机和业务用例 +src/WxAgent.Windows net8.0-windows:FlaUI、UIA 和 Win32 +src/WxAgent.Host net8.0-windows:Desktop Agent 和 CLI +tests/WxAgent.Core.Tests Linux 可执行的纯逻辑测试 +tests/WxAgent.Windows.Tests Windows 真机测试,需要时再创建 +``` + +- 保持 Windows 专用代码与纯业务逻辑分离,使 Core 测试可在 Linux 执行。 +- 不为单一实现建立无实际用途的通用框架;跨平台边界和真机测试所需的最小抽象除外。 + +## Linux 开发与构建 + +- Linux 负责主要开发、代码检查、全部项目编译和 Core 测试。 +- Windows UIA 代码可在 Linux 编译,但不能在 Linux 执行。 +- Windows 项目必须设置 `true`。 +- 暂不使用 NativeAOT;发布时关闭 trimming。 + +```bash +dotnet restore WxAgent.sln -p:EnableWindowsTargeting=true +dotnet test tests/WxAgent.Core.Tests -c Release +dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true +dotnet publish src/WxAgent.Host -c Release -r win-x64 --self-contained true \ + -p:EnableWindowsTargeting=true -p:PublishSingleFile=true -p:PublishTrimmed=false +``` + +## Windows 真机操作 + +### 连接信息 + +- Windows 主机:`10.1.1.101` +- 计算机名:`DESKTOP-EGI7QCK` +- 登录用户:`rogee` +- 用户目录:`C:\Users\Rogee` +- SSH:`ssh rogee@10.1.1.101`,当前 Linux 主机的 SSH 密钥可直接登录。 +- PowerShell:`ssh rogee@10.1.1.101 "powershell -NoProfile -Command '<命令>'"` +- Windows MCP:`http://10.1.1.101:8765/mcp`,配置见 `.mcp.json`。 +- 微信程序:`C:\Program Files\Tencent\Weixin\Weixin.exe` +- 当前 Windows 未发现全局 `dotnet` 命令,优先上传 Linux 生成的 self-contained 发布包;需要 Windows SDK 时可直接安装。 +- 建议部署目录:`C:\Users\Rogee\wx-agent`。 + +不得删除或模糊以上真机连接信息;开发产物必须能够上传到该主机执行生产前真机验证。 + +- 优先使用 Windows MCP 操作和检查 Windows 主机。 +- MCP 无法满足需求或明显不便时,改用 SSH/PowerShell 上传产物、执行命令和收集日志。 +- 调试需要的应用、SDK、检查工具或依赖可以直接安装,无需事先确认。 +- 微信自动化必须运行在微信所在的已登录、未锁定用户会话中,不能在 Session 0 中验证。 +- 默认只使用“文件传输助手”和专用测试账号;未经用户明确要求,不操作真实联系人和群聊。 + +上传示例: + +```bash +ssh rogee@10.1.1.101 'powershell -NoProfile -Command "New-Item -ItemType Directory -Force C:\Users\Rogee\wx-agent | Out-Null"' +scp -r src/WxAgent.Host/bin/Release/net8.0-windows10.0.19041.0/win-x64/publish/* \ + rogee@10.1.1.101:'C:/Users/Rogee/wx-agent/' +``` + +每次 Windows 发布至少执行: + +```powershell +WxAgent.Host doctor +WxAgent.Host inspect-ui --output artifacts/ui-tree.json +WxAgent.Host smoke +``` + +## UI Automation 规则 + +- 同一个微信窗口的点击、输入、滚动及页面切换必须进入单一命令队列,禁止并发写操作。 +- UIA 事件回调只采集必要信息并快速返回,不在回调线程执行耗时业务逻辑。 +- 不长期持有 UIA 元素对象;控件可能因页面虚拟化随时失效。 +- 消息监听采用“UIA 事件 + 低频轮询 + 可见快照比较 + 有界去重”。 +- 读取和查找操作可以有限重试;发送消息、联系人变更、群成员变更和朋友圈操作不得盲目重试。 + +控件定位顺序固定为: + +```text +AutomationId +→ ControlType + Name +→ 稳定祖先节点 + 相对关系 +→ UIA Pattern +→ 集中维护的坐标兜底 +``` + +- 禁止在业务代码中散落绝对坐标。 +- 定位器、消息识别规则和控件路径集中维护。 +- 当前已知关键节点包括 `MainView`、`session_list`、`chat_message_page`、`chat_message_list`、`chat_input_field` 和 `tool_bar_accessible`;完整基线见开发计划。 + +## 测试与完成标准 + +- 非平凡解析、去重、状态机或分支逻辑必须留下一个最小可运行测试。 +- UIA 节点应尽早转换为脱敏、可序列化的快照,使消息解析和定位规则能在 Linux 离线测试。 +- 每项功能必须具备:公共调用方式、超时/取消行为、明确错误码、测试或真机检查、当前微信版本验收记录和失败诊断信息。 +- 变更完成前先运行相关 `dotnet test`、`dotnet build`;涉及 Windows 自动化时还必须在真机执行对应 smoke。 +- 日志默认不得记录完整消息内容、联系人名称、附件内容或未脱敏 UI 树。 + +## 功能实现原则 + +- 公开行为以 wxautox4 官方文档和 `docs/WxAgent-CSharp-开发计划.md` 中的功能复制矩阵为验收基线。 +- 优先复用 FlaUI、CsWin32 和 .NET 标准库,不从零重写已有能力。 +- 修复 UI 兼容问题时应修改共享定位器或解析规则,禁止只给单个调用路径打补丁。 +- 微信版本变化时先运行 `doctor/smoke`、比较 UI 树快照,再集中更新定位器并执行真机回归。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..435a74c --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# WxAgent + +WxAgent 使用 C#/.NET 8 独立实现微信 Windows 桌面端自动化能力。当前已完成 **M1 技术验证**、M2 会话/消息扩展切片、M3 监听和完整消息模型,并包含只读数据库 MVP。 + +## 当前能力 + +- `doctor`:检测 Windows 会话、`Weixin.exe` 进程/版本/只读访问权限、微信窗口、关键 UIA 控件和数据库根目录。 +- `inspect-ui`:通过 FlaUI.UIA3 导出脱敏 UI 树;名称和动态 AutomationId 只保留长度和短哈希。 +- `session list/search/current/open`:列出可见会话、输出分类搜索结果和精确匹配、读取及切换当前会话。 +- `chat send/read/history/listen/monitor`:仅以文件传输助手做默认验证,支持文本发送、可见及历史消息读取,并以 UIA 结构事件结合两秒低频轮询实时发现消息。 +- `chat monitor`:逐行输出 `MessageEvent` JSON,使用有界语义指纹去重和受当前用户 ACL 保护的 checkpoint,支持 Host 重启后的短期续接;默认隐藏正文和引用内容。 +- 消息模型覆盖文本、图片、文件、视频、语音、链接、引用和系统消息;公开监听 API 为 `IAsyncEnumerable`,可选回调逐个隔离异常。 +- `chat reply-latest`:通过消息行实时边界右侧气泡定位执行引用回复;解析微信 `回复文本\n引用 <发送者> 的消息 : <原文>` 格式。 +- `chat send-file/send-image`:使用剪贴板粘贴发送文件或真实图片数据,不打开文件选择框,并恢复常见文本、图片或文件剪贴板内容。 +- `smoke`:执行 `doctor`、脱敏 UI 树导出,并向文件传输助手发送唯一 ASCII 标记后读回确认。 +- 文本输入使用 UIA ValuePattern 直接设置并在发送前逐字校验,避免中文输入法组合态改变消息内容。 +- `db scan`:以 `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` 扫描 WCDB 十六进制候选,只保存通过具体数据库 page 1 HMAC-SHA512 验证的密钥。 +- `db query`:通过 SQLCipher 4 以文件系统 ReadOnly、私有缓存、无连接池和 `query_only` 读取 `sqlite_master` 元数据,并确认写操作被拒绝。 + +## 安全边界 + +允许读取本机 `Weixin.exe` 的已提交可读内存和本机加密数据库。禁止 DLL 注入、内存修改、数据库写入、协议破解、登录/验证码/风控绕过。候选密钥不会被输出;只有带 `--save` 的显式命令才会将已验证密钥原子保存到当前用户 LocalAppData,并应用当前用户 ACL。当前受控环境使用明文 hex key 存储,尚未引入 DPAPI。 + +没有进程句柄到数据库文件路径关联时,`PageHmacVerified` 仅证明密钥属于具体数据库,不表示账号当前活跃。 + +## 构建 + +```bash +export PATH="$HOME/.dotnet:$PATH" +dotnet restore WxAgent.sln -p:EnableWindowsTargeting=true +dotnet test tests/WxAgent.Core.Tests -c Release +dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true +dotnet publish src/WxAgent.Host -c Release -r win-x64 --self-contained true \ + -p:EnableWindowsTargeting=true -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=false +``` + +## CLI + +```powershell +WxAgent.Host doctor +WxAgent.Host inspect-ui --output artifacts/ui-tree.json +WxAgent.Host smoke --timeout 45 +WxAgent.Host chat send --text "hello" +WxAgent.Host session list +WxAgent.Host session search --query "文件传输助手" --exact +WxAgent.Host session current +WxAgent.Host session open --name "文件传输助手" +WxAgent.Host chat read --limit 20 +WxAgent.Host chat read --limit 20 --include-content +WxAgent.Host chat history --limit 100 --scrolls 10 +WxAgent.Host chat reply-latest --text "quoted reply" +WxAgent.Host chat send-file --path C:\path\file.txt +WxAgent.Host chat send-image --path C:\path\image.png +WxAgent.Host chat listen --seconds 30 +WxAgent.Host chat monitor --seconds 60 --state-file artifacts\listener-state.json +WxAgent.Host db scan --timeout 120 +WxAgent.Host db scan --save --timeout 120 +WxAgent.Host db status +WxAgent.Host db query --account --database contact/contact.db +``` + +默认密钥文件:`%LOCALAPPDATA%\WxAgent\database-keys.json`。CLI 和日志不会打印完整密钥。 + +## Windows 真机验证 + +目标主机:`10.1.1.101`,用户 `rogee`,微信 `C:\Program Files\Tencent\Weixin\Weixin.exe`。UIA 命令必须在微信所在的已登录、未锁定交互会话中运行;SSH Session 0 只能用于部署和非 UI 数据库命令。 + +```bash +scp -r src/WxAgent.Host/bin/Release/net8.0-windows10.0.19041.0/win-x64/publish/* \ + rogee@10.1.1.101:'C:/Users/Rogee/wx-agent/' +``` + +发布后至少运行: + +```powershell +WxAgent.Host doctor +WxAgent.Host inspect-ui --output artifacts/ui-tree.json +WxAgent.Host smoke +WxAgent.Host db scan --save --timeout 180 +WxAgent.Host db status +WxAgent.Host db query --account --database +``` + +M1 已完成文件传输助手激活、文本发送/读回、新消息发现和微信重启后的重新连接验证。M2 扩展切片已完成会话搜索精确匹配、引用回复解析、历史滚动读取及去重;验收见 `docs/validation/M2-Search-Quote-History-2026-09-04.md`。M3 已完成实时监听、轮询兜底、完整消息类型、有界去重、回调隔离和 checkpoint 重启续接;验收见 `docs/validation/M3-Listening-2026-09-04.md`。重启后若微信要求手机确认登录,必须由用户正常确认;WxAgent 不绕过登录流程。 + +`SQLitePCLRaw.bundle_e_sqlcipher` 2.1.11 仅用于当前开发与真机验证。正式商业发布前必须完成许可与安全基线评审;若不能接受其已弃用状态、SQLCipher 4.5.2 community 和 SQLite 3.39.2,则切换 Zetetic 官方 SQLCipher for .NET 后重新执行全部数据库验收。 diff --git a/WxAgent.sln b/WxAgent.sln new file mode 100644 index 0000000..745b5d7 --- /dev/null +++ b/WxAgent.sln @@ -0,0 +1,50 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{11E9B433-75E3-446D-B8FF-958A0F541BA5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WxAgent.Core", "src\WxAgent.Core\WxAgent.Core.csproj", "{EDC68F82-2F67-40A5-B6CA-B4D26349E988}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WxAgent.Windows", "src\WxAgent.Windows\WxAgent.Windows.csproj", "{2552B68F-0F33-4B71-AE13-CF53ACD15D4D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WxAgent.Host", "src\WxAgent.Host\WxAgent.Host.csproj", "{70B4D2DF-1FDC-4C80-85A0-F03B1A979915}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{CE7C3E9D-F9B0-4A30-AA6F-A645D75888D9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WxAgent.Core.Tests", "tests\WxAgent.Core.Tests\WxAgent.Core.Tests.csproj", "{1F24FB68-6917-4984-89FC-5F9C64E2965D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {EDC68F82-2F67-40A5-B6CA-B4D26349E988}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EDC68F82-2F67-40A5-B6CA-B4D26349E988}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EDC68F82-2F67-40A5-B6CA-B4D26349E988}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EDC68F82-2F67-40A5-B6CA-B4D26349E988}.Release|Any CPU.Build.0 = Release|Any CPU + {2552B68F-0F33-4B71-AE13-CF53ACD15D4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2552B68F-0F33-4B71-AE13-CF53ACD15D4D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2552B68F-0F33-4B71-AE13-CF53ACD15D4D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2552B68F-0F33-4B71-AE13-CF53ACD15D4D}.Release|Any CPU.Build.0 = Release|Any CPU + {70B4D2DF-1FDC-4C80-85A0-F03B1A979915}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70B4D2DF-1FDC-4C80-85A0-F03B1A979915}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70B4D2DF-1FDC-4C80-85A0-F03B1A979915}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70B4D2DF-1FDC-4C80-85A0-F03B1A979915}.Release|Any CPU.Build.0 = Release|Any CPU + {1F24FB68-6917-4984-89FC-5F9C64E2965D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F24FB68-6917-4984-89FC-5F9C64E2965D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F24FB68-6917-4984-89FC-5F9C64E2965D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F24FB68-6917-4984-89FC-5F9C64E2965D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {EDC68F82-2F67-40A5-B6CA-B4D26349E988} = {11E9B433-75E3-446D-B8FF-958A0F541BA5} + {2552B68F-0F33-4B71-AE13-CF53ACD15D4D} = {11E9B433-75E3-446D-B8FF-958A0F541BA5} + {70B4D2DF-1FDC-4C80-85A0-F03B1A979915} = {11E9B433-75E3-446D-B8FF-958A0F541BA5} + {1F24FB68-6917-4984-89FC-5F9C64E2965D} = {CE7C3E9D-F9B0-4A30-AA6F-A645D75888D9} + EndGlobalSection +EndGlobal diff --git a/docs/WxAgent-CSharp-开发计划.md b/docs/WxAgent-CSharp-开发计划.md new file mode 100644 index 0000000..7af327b --- /dev/null +++ b/docs/WxAgent-CSharp-开发计划.md @@ -0,0 +1,646 @@ +# WxAgent C# 技术开发计划 + +> 版本:0.2 +> 日期:2026-09-03 +> 目标:使用 C# 独立实现 wxautox4 商业版的业务功能;只兼容功能,不兼容 Python API。第一阶段不实现 Windows Service 和正式控制面。 + +## 1. 结论 + +可以采用以下开发方式: + +- Linux 作为主要开发、代码审查、构建和纯逻辑测试环境。 +- Linux 交叉编译 `win-x64` 发布包。 +- Windows 真机运行 Desktop Agent,在已登录且未锁定的用户会话中连接微信并执行 UI Automation 验证。 +- 当前项目的 Windows MCP 已能识别微信窗口及关键 UIA 节点,可作为第一台真机验证环境。 + +限制: + +- Linux 只能编译 Windows UIA 代码,不能执行 FlaUI、COM、剪贴板、窗口和微信操作测试。 +- Windows 真机必须保持用户登录、桌面可交互、微信已登录。 +- 暂不使用 NativeAOT;普通 self-contained/single-file 发布可从 Linux 生成。安装包、代码签名和 UIA 真机测试放在 Windows 完成。 + +## 2. 范围 + +### 2.1 包含 + +- 微信进程、窗口和登录状态检测 +- 会话列表、会话切换和子窗口 +- 消息发送、读取、解析、监听和去重 +- 文本、图片、文件、语音、链接等消息类型 +- 联系人、好友申请和好友信息 +- 群聊创建、成员和群信息管理 +- 历史消息、附件下载、OCR、语音转文字 +- 朋友圈读取、发布、点赞和评论 +- 本地 Desktop Agent、CLI、日志和诊断工具 +- 只读扫描 `Weixin.exe` 内存,校验 SQLCipher 密钥并读取本地微信数据库 +- 面向未来 Web/UI 控制面的应用层 API + +### 2.2 暂不包含 + +- Windows Service +- 正式 Web UI 或桌面 UI +- SaaS、多租户和远程集群控制 +- 商业授权、计费和自动升级 +- 绕过登录、验证码、风控或平台限制 +- 微信协议、数据库写入、DLL 注入或进程内存修改 + +只读数据库例外严格限定为:以最小进程权限读取 `Weixin.exe` 内存中的 WCDB 候选密钥,以目标数据库 page 1 HMAC-SHA512 验证后,使用 SQLCipher 4 只读连接查询本机数据库。禁止输出未验证候选、记录完整密钥、修改数据库或将该能力扩展为协议/登录绕过。 + +## 3. 最小项目结构 + +第一阶段使用以下结构,不提前拆分微服务: + +```text +WxAgent.sln +├── src/ +│ ├── WxAgent.Core/ # net8.0,模型、解析、去重、状态机、业务用例 +│ ├── WxAgent.Windows/ # net8.0-windows,FlaUI/UIA/Win32 实现 +│ └── WxAgent.Host/ # net8.0-windows,Desktop Agent 和 CLI +└── tests/ + └── WxAgent.Core.Tests/ # net8.0,可在 Linux 执行 +``` + +开始真机自动化后再增加: + +```text +tests/WxAgent.Windows.Tests/ # 仅在 Windows 真机执行 +``` + +这种拆分的目的不是建立通用插件框架,而是让纯业务逻辑能够在 Linux 运行测试,同时隔离 Windows 专用引用。 + +## 4. 技术选型 + +| 能力 | 选型 | 说明 | +| --- | --- | --- | +| Runtime | .NET 8 LTS,后续可升级 .NET 10 | 优先使用成熟工具链 | +| UI Automation | FlaUI.UIA3 | 首选封装,缺失能力再直接调用 UIA COM | +| Win32 | Microsoft.Windows.CsWin32 | 按需生成 P/Invoke,不手写大批声明 | +| 并发 | `Channel`、`Task`、`IAsyncEnumerable` | 所有 UI 写操作进入单队列 | +| 配置 | `Microsoft.Extensions.Configuration` | JSON + 环境变量 | +| 日志 | `Microsoft.Extensions.Logging` | 暂不额外引入日志框架 | +| 序列化 | `System.Text.Json` | UI 快照、命令和事件 | +| 持久化 | SQLite | 仅保存消息游标、短期去重和任务状态 | +| 微信数据库只读 | Microsoft.Data.Sqlite.Core + SQLCipher bundle | raw key、只读模式、`query_only`,不写库 | +| 测试 | xUnit | Linux 纯逻辑测试 + Windows 真机测试 | +| 发布 | self-contained `win-x64` | 第一阶段只支持 Windows x64 | + +除非验证证明 FlaUI 无法满足需求,否则不要从零封装完整 UI Automation COM API。 + +## 5. Linux 编译、Windows 验证方案 + +### 5.1 Windows 项目配置 + +`WxAgent.Windows.csproj` 和 `WxAgent.Host.csproj` 使用: + +```xml + + + net8.0-windows10.0.19041.0 + true + win-x64 + x64 + enable + enable + false + + +``` + +`EnableWindowsTargeting` 允许 Linux/macOS 恢复和编译 Windows Targeting Pack。`PublishTrimmed` 暂时关闭,避免 FlaUI、COM 或反射代码被错误裁剪。 + +### 5.2 Linux 构建命令 + +```bash +dotnet restore WxAgent.sln -p:EnableWindowsTargeting=true +dotnet test tests/WxAgent.Core.Tests -c Release +dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true +dotnet publish src/WxAgent.Host \ + -c Release \ + -r win-x64 \ + --self-contained true \ + -p:EnableWindowsTargeting=true \ + -p:PublishSingleFile=true \ + -p:PublishTrimmed=false +``` + +发布产物: + +```text +src/WxAgent.Host/bin/Release/net8.0-windows10.0.19041.0/win-x64/publish/ +``` + +### 5.3 Windows 真机验证流程 + +```text +Linux build/test/publish + ↓ +复制 publish 目录到 Windows + ↓ +Windows 用户登录并启动微信 + ↓ +运行 WxAgent.Host doctor + ↓ +运行 smoke 测试 + ↓ +保存 UI 树、日志和测试报告 +``` + +优先通过 Windows MCP 操作和检查微信;MCP 不足时,再通过 SSH/PowerShell 上传、启动和收集结果。 + +建议 CLI: + +```powershell +WxAgent.Host doctor +WxAgent.Host inspect-ui --output artifacts/ui-tree.json +WxAgent.Host status +WxAgent.Host conversations +WxAgent.Host open-chat "文件传输助手" +WxAgent.Host send-text "文件传输助手" "wxagent smoke test" +WxAgent.Host messages --visible +WxAgent.Host listen --seconds 60 +WxAgent.Host smoke +``` + +### 5.4 自动化边界 + +- Linux CI:编译全部项目,执行 `WxAgent.Core.Tests`。 +- Windows 真机:执行 `WxAgent.Windows.Tests` 和 smoke/endurance 测试。 +- 安装包、签名、FlaUI/UIA 执行、剪贴板和截图只能在 Windows 验证。 +- 微信操作不能在 Windows Service 的 Session 0 中运行。 + +## 6. 当前真机 UIA 基线 + +2026-09-03 已通过 Windows MCP 对当前微信窗口进行 UI Automation 检查。已确认以下节点可见: + +| 区域 | ControlType | AutomationId/名称 | +| --- | --- | --- | +| 主视图 | Group | `MainView` | +| 左侧导航 | ToolBar | `MainView.main_tabbar` | +| 会话列表 | List | `session_list` | +| 会话项 | ListItem | `session_item_{会话名称}` | +| 聊天页面 | Group | `chat_message_page` | +| 消息列表 | List | `chat_message_list` | +| 消息项 | ListItem | `chat_message_list.qt_scrollarea_viewport.chat_bubble_item_view` | +| 输入框 | Edit | `chat_input_field` | +| 输入区工具栏 | ToolBar | `tool_bar_accessible` | +| 当前会话标题 | Text | 路径末端 `current_chat_name_label` | +| 发送按钮 | Button | 名称 `发送` | +| 发送文件按钮 | Button | 名称 `发送文件` | +| 朋友圈入口 | Button | 名称 `朋友圈` | +| 通讯录入口 | Button | 名称 `通讯录` | +| 更多入口 | Button | `MainView.main_tabbar.tabbar_setting` | + +这份基线可以直接用于第一版定位器,不需要重新从桌面坐标开始摸索。AutomationId 不是微信公开稳定接口,必须保留 Name、ControlType、祖先节点和相对关系作为回退条件。 + +定位顺序统一为: + +```text +AutomationId +→ ControlType + Name +→ 稳定祖先节点 + 相对关系 +→ UIA Pattern +→ 坐标兜底 +``` + +禁止业务代码直接散落绝对坐标。确需坐标时集中登记,并记录适用 DPI、窗口尺寸和升级路径。 + +## 7. 功能参考对象 + +### 7.1 功能行为基线:wxautox4 公开文档 + +以下文档作为“实现什么”和“如何验收”的主要参考,不复制或反编译商业包实现: + +| 范围 | 参考地址 | +| --- | --- | +| 安装与支持环境 | | +| 快速开始 | | +| 基础概念 | | +| 完整示例 | | +| WeChat 功能 | | +| Chat 功能 | | +| Message 类型与动作 | | +| Session 功能 | | +| Moment 功能 | | +| 其他模型 | | +| CLI 行为参考 | | +| CLI 命令 | | +| 常见问题 | | +| 可接受使用政策 | | +| 商业许可 | | +| 微信历史版本参考 | | + +行为复制原则: + +1. 依据公开文档建立功能矩阵。 +2. 使用测试账号观察输入、输出和错误场景。 +3. 独立设计 C# 模型和实现。 +4. 不反编译、不搬运商业包源码或受保护资源。 + +### 7.2 C#/UIA 实现参考 + +| 主题 | 参考地址 | +| --- | --- | +| FlaUI 源码与示例 | | +| FlaUI UIA3 实现 | | +| FlaUI Inspect 工具 | | +| CsWin32 | | +| UI Automation Client 概览 | | +| UIA 树结构 | | +| UIA 客户端线程模型 | | +| UIA 客户端缓存 | | +| UIA 事件 | | +| 虚拟化控件 | | +| ItemContainer Pattern | | +| Inspect.exe | | +| Accessibility Insights | | +| Linux 构建 Windows Target | | +| dotnet publish | | + +优先阅读顺序: + +1. wxautox4 的 `WeChat`、`Chat`、`Message` 文档。 +2. FlaUI UIA3 示例。 +3. Microsoft UIA threading、tree、events、cache、virtualized items。 +4. 使用 FlaUInspect/Accessibility Insights 对照当前微信控件树。 + +## 8. 功能复制矩阵 + +### 8.1 WeChat 级能力 + +| wxautox4 公开能力 | C# 计划能力 | 主要实现位置 | 阶段 | +| --- | --- | --- | --- | +| `KeepRunning` | Desktop Agent 生命周期 | Host | M1 | +| `GetSession` | 获取当前会话列表 | SessionReader | M2 | +| `ChatWith` | 搜索并打开会话 | ChatNavigator | M2 | +| `GetSubWindow` | 查找聊天子窗口 | WindowRegistry | M4 | +| `GetAllSubWindow` | 枚举聊天子窗口 | WindowRegistry | M4 | +| `AddListenChat` | 注册监听目标 | MessageMonitor | M3 | +| `RemoveListenChat` | 移除监听目标 | MessageMonitor | M3 | +| `StartListening` | 开始监听 | MessageMonitor | M3 | +| `StopListening` | 停止监听 | MessageMonitor | M3 | +| `SwitchToChat` | 切换聊天页面 | MainNavigator | M2 | +| `SwitchToContact` | 切换通讯录 | MainNavigator | M4 | +| `Moments` | 进入朋友圈 | MomentsNavigator | M5 | +| `PublishMoment` | 发布朋友圈 | MomentsClient | M5 | +| `GetNewFriends` | 获取好友申请 | ContactClient | M4 | +| `AddNewFriend` | 添加好友 | ContactClient | M4 | +| `EditFriendInfo` | 修改备注等信息 | ContactClient | M4 | +| `GetNextNewMessage` | 获取下一个新消息会话 | MessageMonitor | M3 | +| `GetAllRecentGroups` | 最近群聊列表 | GroupClient | M4 | +| `SendUrlCard` | 发送链接卡片 | MessageSender | M5 | +| `GetHistoryMessage` | 获取历史消息 | HistoryReader | M4 | +| `GetFriendDetails` | 好友列表及详情 | ContactClient | M4 | +| `CreateGroup` | 创建群聊 | GroupClient | M4 | +| `IsOnline` | 登录/在线状态 | WechatConnection | M1 | +| `GetMyInfo` | 获取当前账号信息 | AccountReader | M4 | +| `GetDialog` | 对话框识别与操作 | DialogHandler | M2 | + +### 8.2 Chat 级能力 + +| wxautox4 公开能力 | C# 计划能力 | 阶段 | +| --- | --- | --- | +| `Show` | 激活聊天窗口 | M1 | +| `ChatInfo` | 当前会话信息 | M2 | +| `AtAll` | 群聊 @所有人 | M4 | +| `SendMsg` | 文本、@、引用文本发送 | M2/M3 | +| `SendFiles` | 图片和文件发送 | M2 | +| `SendAudio` | 语音文件发送 | M5 | +| `GetAllMessage` | 可见消息读取 | M2 | +| `AddGroupMembers` | 添加群成员 | M4 | +| `SetGroupName` | 修改群名称 | M4 | +| `SetGroupRemark` | 修改群备注 | M4 | +| `SetGroupAnnouncement` | 修改群公告 | M4 | +| `SetGroupMyNickname` | 修改群昵称 | M4 | +| `Close` | 关闭聊天窗口 | M2 | + +### 8.3 Message 级能力 + +| 类别 | C# 计划能力 | 阶段 | +| --- | --- | --- | +| 通用属性 | 会话、发送人、时间、方向、消息类型、原始 UI 节点摘要 | M2 | +| `roll_into_view` | 虚拟化消息滚动到可见区域 | M3 | +| `click` | 激活消息 | M3 | +| `select_option` | 打开消息菜单并选择动作 | M3 | +| `quote` | 引用回复 | M3 | +| `forward` | 转发 | M4 | +| `tickle` | 拍一拍 | M5 | +| `sender_info` | 读取发送人信息 | M4 | +| `add_friend`/`delete_friend` | 好友操作 | M4 | +| `download`/`save_files` | 图片、文件、视频下载 | M4 | +| `ocr` | 图片 OCR | M4 | +| `to_text` | 语音转文字 | M4 | +| `get_url` | 获取链接 URL | M5 | +| `get_content` | 合并消息或特殊内容读取 | M5 | +| `to_markdown` | 标准化内容输出 | M5 | + +### 8.4 Session 和朋友圈 + +| wxautox4 公开能力 | C# 计划能力 | 阶段 | +| --- | --- | --- | +| Session `search` | 会话搜索 | M2 | +| `go_top`/`roll_up`/`roll_down` | 会话列表滚动 | M2 | +| `click`/`double_click` | 打开主窗口或子窗口 | M2/M4 | +| `delete`/`hide` | 删除或隐藏会话 | M4 | +| `select_option` | 会话上下文菜单 | M4 | +| Moment `GetMoments` | 读取朋友圈 | M5 | +| `Refresh` | 刷新朋友圈 | M5 | +| `Publish` | 发布朋友圈 | M5 | +| `Like` | 点赞 | M5 | +| `Comment` | 评论 | M5 | + +## 9. 核心设计 + +### 9.1 自动化命令队列 + +同一个微信窗口的写操作必须串行: + +```text +业务请求 + → Channel + → 专用 UIA 工作线程 + → 微信窗口 +``` + +不要并行点击、输入或滚动同一个窗口。读取操作也应通过统一调度,避免读取过程中页面被另一个命令切换。 + +### 9.2 UIA 线程 + +- 在专用 Windows 线程初始化 UIA/COM。 +- UIA 事件处理器只采集最小信息并快速返回。 +- 不在 UIA 回调线程执行业务回调或耗时操作。 +- UIA 元素引用可能失效;跨步骤保存 RuntimeId 或轻量快照,不长期持有元素对象。 + +### 9.3 消息监听 + +微信 4.x 消息列表存在虚拟化,监听采用: + +```text +UIA 事件 + + 低频轮询兜底 + + 可见消息快照比较 + + 有界滑动窗口去重 +``` + +消息指纹建议: + +```text +会话标识 + 发送方向 + 发送人 + 消息类型 + 内容摘要 + 时间标记 + 邻近顺序 +``` + +微信没有公开稳定消息 ID,因此指纹只能用于工程去重,不能作为永久业务主键。 + +### 9.4 操作重试 + +允许重试: + +- 查找窗口和控件 +- 页面状态等待 +- 会话读取 +- 消息读取 +- 历史加载 + +禁止盲目重试: + +- 发送消息或文件 +- 添加/删除联系人 +- 群成员变更 +- 发布朋友圈 +- 点赞或评论 + +写操作可能已成功但结果确认失败,自动重试会产生重复或破坏性结果。 + +### 9.5 数据库只读边界 + +```text +Weixin.exe 可读内存 + → WCDB 十六进制候选 + → 具体 db_storage 数据库 page 1 salt/HMAC 验证 + → 按账号根目录指纹和相对路径保存证据 + → SQLCipher 4 ReadOnly + query_only 查询 +``` + +- 扫描每个 `Weixin.exe` PID,只读取 `MEM_COMMIT` 且可读、非 guard/no-access 的区域。 +- 账号身份使用规范化 `db_storage` 根目录指纹;PID、昵称、微信号或文件名均不能单独作为身份。 +- 在实现进程句柄到数据库路径关联前,绑定置信度只能标记为 `PageHmacVerified`,不能宣称账号当前 active。 +- 密钥保存必须由 CLI 显式请求,采用原子替换和当前用户 ACL;受控环境暂不引入 DPAPI。 +- SQLCipher 连接只允许文件系统只读模式、私有缓存、关闭池、`PRAGMA query_only=ON` 和有界查询。 + +### 9.6 统一错误码 + +至少包含: + +```text +WechatNotRunning +WechatNotLoggedIn +WindowNotFound +ControlNotFound +UiStructureChanged +Timeout +PermissionMismatch +SessionLocked +UnsupportedWechatVersion +ResultUnconfirmed +OperationCancelled +InvalidOperationState +``` + +## 10. 里程碑和开发顺序 + +### M0:功能基线,1–2 周 + +- 冻结功能矩阵 +- 固定首个微信版本和 Windows 版本 +- 准备测试账号、测试群和文件传输助手场景 +- 保存首份脱敏 UI 树基线 + +验收:每项功能有输入、输出、错误场景和对应文档地址。 + +### M1:技术验证,2–3 周 + +M1A/Foundation + Database MVP 首先交付工程基础、`doctor`、脱敏 `inspect-ui`、只读密钥扫描和 SQLCipher 元数据查询。该切片完成不代表 M1 完成。 + +按顺序实现: + +1. `doctor`:检测 Windows、微信进程、窗口、登录状态和权限。 +2. `inspect-ui`:导出脱敏 UI 树。 +3. 查找 `session_list`、`chat_message_list`、`chat_input_field`。 +4. 打开文件传输助手。 +5. 发送一条文本。 +6. 读取发送后的可见消息。 +7. 接收并发现一条新消息。 +8. 微信重启后重新连接。 + +状态(2026-09-04):M1 已完成。文件传输助手激活优先使用 AutomationId;文本通过 UIA ValuePattern 设置并在点击发送前逐字校验,避免输入法组合态;新消息采用 UIA 结构事件加两秒低频轮询;微信重启并由用户正常完成手机登录确认后可重新连接并通过消息级 smoke。 + +Go/No-Go:通过。消息发送、读取和监听未依赖绝对坐标;坐标点击仅使用 UIA 元素实时边界中心作为集中兜底。 + +### M2:基础消息,4–6 周 + +- 会话列表和搜索 +- 打开会话 +- 当前会话信息 +- 发送文本、图片和文件 +- 读取可见消息 +- 基础消息类型解析 +- 统一错误和超时 + +验收:连续发送 500 条测试消息无重复;100%、125%、150% DPI 可用。 + +进度(2026-09-04):本轮 M2 扩展切片已完成会话搜索结果输出和逐行精确匹配、会话切换、文件/图片剪贴板发送、微信真实引用回复格式解析,以及文件传输助手可见历史滚动读取。历史消息使用可访问文本与同文本出现序号生成稳定指纹,并经过有界去重;默认 CLI 输出不包含消息或引用正文。该切片真机验收通过,但不替代 M2 的 500 条连续发送及多 DPI 总体验收。 + +### M3:监听和完整消息模型,4–6 周 + +- 实时消息监听 +- 轮询兜底 +- 消息去重 +- 文本、图片、文件、视频、语音、引用、系统消息等解析 +- Agent 重启后的短期续接 +- `IAsyncEnumerable` 事件输出 + +验收:持续监听 8 小时,消息无明显遗漏和重复;回调异常不影响监听器。 + +进度(2026-09-04):M3 功能实现完成。监听采用 UIA 结构事件快速唤醒和两秒轮询兜底,公开 `IAsyncEnumerable`,支持 checkpoint 恢复、`Reconnected` 事件、有界去重、逐回调异常隔离及默认脱敏 JSONL 输出。文本、图片、文件、视频、语音、链接、引用和系统类型均有解析覆盖。真机完成实时事件、Host 停止期间消息恢复、RuntimeId 变化后不重复输出和约 1.6 小时稳定性验证;稳定性阶段 20/20 条事件、0 重复、0 内容泄漏。完整 8 小时持续运行由用户明确取消,不再作为本轮完成阻塞;详见 `docs/validation/M3-Listening-2026-09-04.md`。 + +### M4:联系人、群聊、历史和附件,6–10 周 + +- 联系人与好友申请 +- 好友备注和详情 +- 群成员、群名称、公告和昵称 +- 创建群聊 +- 历史消息滚动读取 +- 图片、文件和视频下载 +- OCR、语音转文字 + +验收:所有破坏性操作均有显式确认,且不会自动重试。 + +### M5:朋友圈和低频功能,5–8 周 + +- 朋友圈读取、发布、点赞、评论 +- 链接卡片 +- 合并消息和特殊消息内容 +- 多聊天子窗口 +- 功能矩阵剩余项目 + +验收:公开功能矩阵逐项关闭,不以“基本支持”代替明确结果。 + +### M6:稳定性,6–10 周 + +- 控件失效恢复 +- 微信重启恢复 +- 弹窗和页面状态恢复 +- Windows 10/11、不同 DPI 和窗口尺寸 +- 24–72 小时监听 +- 一万条消息去重测试 +- 版本变化诊断 +- 内存、句柄和 COM 对象检查 + +## 11. 测试计划 + +### 11.1 Linux 可执行测试 + +- 消息模型和序列化 +- 文本、时间和消息类型解析 +- 消息指纹与去重 +- 状态机 +- 错误映射 +- UI 树快照的离线节点匹配 +- 文件名和路径处理 + +UIA 节点先转换成可序列化的 `UiNodeSnapshot`,解析器尽量针对快照工作,以便 Linux 执行测试。 + +### 11.2 Windows 真机 smoke + +每次发布至少执行: + +1. 检测微信和登录状态。 +2. 切换文件传输助手。 +3. 发送唯一测试文本。 +4. 在消息列表读回测试文本。 +5. 发送一个小文件。 +6. 下载或确认文件消息。 +7. 开启 60 秒监听并从另一测试账号发送消息。 +8. 保存脱敏 UI 树和测试报告。 + +### 11.3 Windows endurance + +- 8、24、72 小时监听 +- 微信重启 20 次 +- 网络断开/恢复 +- 会话快速切换 +- 长消息列表滚动 +- 多群并发产生消息 +- 窗口尺寸和 DPI 变化 + +### 11.4 完成定义 + +每个功能必须具备: + +- C# 公共调用方式 +- 超时和取消行为 +- 明确错误码 +- 至少一个纯逻辑或真机检查 +- 当前微信版本的验收记录 +- 失败时可生成诊断信息 + +## 12. 诊断和升级策略 + +每次 UIA 失败记录: + +- 微信版本 +- Windows 版本和 DPI +- 当前页面和窗口状态 +- 控件定位条件 +- 找到的候选节点摘要 +- 操作阶段和超时时间 +- Correlation ID +- 可选脱敏 UI 树和截图 + +微信升级后的处理流程: + +```text +运行 doctor/smoke +→ 与上一个 UI 树快照比较 +→ 更新集中定位器 +→ 运行离线快照测试 +→ Windows 真机回归 +``` + +定位器、消息识别规则和控件路径集中维护,避免每个功能自行查找控件。这是减少后续二次摸索的关键。 + +## 13. 人员和预计成本 + +推荐: + +- 1 名资深 C#/Windows UIA 工程师 +- 1 名 C# 工程师 +- 0.5 名真机测试工程师 + +预计: + +| 目标 | 两人团队工期 | 投入 | +| --- | ---: | ---: | +| 技术验证 | 3–5 周 | 1–2 人月 | +| 基础消息能力 | 2–3 个月 | 4–6 人月 | +| 高频功能覆盖 | 4–5 个月 | 7–10 人月 | +| 完整功能覆盖 | 6–8 个月 | 9–15 人月 | +| 商业稳定版本 | 8–11 个月 | 14–22 人月 | + +不实现 Service 只能节省少量投入,主要工作仍然是微信 UI 行为识别、消息解析和版本兼容。 + +## 14. 第一批开发任务 + +按以下顺序直接开始,不再重新做技术选型: + +1. 创建解决方案和四个初始项目。 +2. 配置 Linux Windows targeting 和 `win-x64` publish。 +3. 引入 FlaUI.UIA3、CsWin32 和只读 SQLCipher 运行时。 +4. 实现 `doctor`、微信窗口连接和数据库根目录诊断。 +5. 实现 UI 树脱敏导出。 +6. 固化本文件列出的关键 AutomationId。 +7. 实现打开文件传输助手。 +8. 实现发送文本并读回确认。 +9. 实现可见消息快照与去重。 +10. 实现只读进程内存扫描、page 1 HMAC 校验、多账号密钥证据和 SQLCipher 元数据查询。 +11. 在当前 Windows 真机运行第一轮 smoke。 + +第一轮只使用文件传输助手和专用测试账号,不操作真实联系人或群聊。 diff --git a/docs/validation/M1-2026-09-04.md b/docs/validation/M1-2026-09-04.md new file mode 100644 index 0000000..3b26c64 --- /dev/null +++ b/docs/validation/M1-2026-09-04.md @@ -0,0 +1,38 @@ +# M1 技术验证验收记录(2026-09-04) + +## 环境 + +- Windows:10.0.19044,主机 `DESKTOP-EGI7QCK` +- 用户会话:`Rogee`,Session 1,活动 console +- 微信:4.1.8.29 +- 部署目录:`C:\Users\Rogee\wx-agent` +- 默认测试目标:文件传输助手 + +## Linux 检查 + +```bash +dotnet test tests/WxAgent.Core.Tests -c Release +dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true +dotnet publish src/WxAgent.Host -c Release -r win-x64 --self-contained true \ + -p:EnableWindowsTargeting=true -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=false +``` + +结果:Core 测试 9/9 通过;Release 构建 0 warnings、0 errors;单文件发布成功。 + +## 真机结果 + +- `doctor`:登录后主窗口、`MainView` 和 `session_list` 检测正常;未选择聊天时不再误报 UI 结构变化。 +- 文件传输助手:可按会话 AutomationId 直接激活;会话未出现时可通过搜索结果 AutomationId 激活。 +- `chat send`:唯一 ASCII 标记发送成功,随后 `chat read --include-content` 精确读回。 +- 输入法兼容:最初使用模拟键盘输入时观察到输入法组合态改变消息。改为 UIA ValuePattern 直接设置文本,并在发送前逐字读取校验;不一致时中止发送,不执行盲目重试。修复后发送和精确读回通过。 +- `chat listen`:监听期间由另一个 CLI 进程发送唯一标记;UIA 结构事件配合两秒低频轮询发现 1 条新消息。 +- `smoke --timeout 45`:`doctor`、脱敏 UI 树、唯一文本发送和读回确认全部通过;导出 168 个脱敏节点。 +- 微信重启恢复:停止所有 Weixin 进程并重新启动后,微信要求手机确认登录;用户正常确认后出现 5 个 Weixin 进程,CLI 无需重启服务即可重新附加,发送、读取、监听和消息级 smoke 再次通过。 +- Session 断开:RDP 会话断开时输入 API 返回 access denied/invalid handle;恢复为活动 console 后正常。该行为由 `doctor`/`SessionLocked` 边界约束,不尝试绕过桌面会话限制。 + +## 结论 + +M1 Go/No-Go:**Go**。 + +发送、读取、监听和恢复均通过 UIA 元素定位及实时边界中心兜底完成,没有维护固定绝对坐标。发送写操作只执行一次;若输入内容或发送结果无法确认,返回 `ResultUnconfirmed`,不自动重试。 diff --git a/docs/validation/M1A-2026-09-04.md b/docs/validation/M1A-2026-09-04.md new file mode 100644 index 0000000..c5b238d --- /dev/null +++ b/docs/validation/M1A-2026-09-04.md @@ -0,0 +1,32 @@ +# M1A/Foundation + Database MVP 真机验收 + +日期:2026-09-04 +主机:`DESKTOP-EGI7QCK` / Windows `10.0.19044.0` +微信:`Weixin.exe 4.1.8.29` +状态:M1A 通过;M1 仍未完成。 + +## Linux + +- .NET SDK:`8.0.424` +- `dotnet test tests/WxAgent.Core.Tests -c Release`:7/7 通过。 +- `dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true`:0 warning / 0 error。 +- self-contained single-file `win-x64` 发布成功;产物约 182 MB,包含自提取 SQLCipher native library。 + +## Windows + +- `doctor`:发现 4 个 Weixin 进程,版本均为 4.1.8.29;4/4 可用最小只读权限打开;发现 1 个账号数据库根目录。 +- SSH 在 Session 0 运行时正确报告 `SessionLocked`,不伪装为 UIA 成功。 +- `inspect-ui`:通过 InteractiveToken 任务在 Session 1 执行成功;导出 166 个节点。离线校验确认所有非空 Name 均为长度+短哈希,AutomationId 仅保留已知结构常量或长度+短哈希;未出现原始 `session_item_*` 后缀及明文“微信”/“发送”等名称。 +- `smoke`:在 Session 1 执行成功,`doctor` 无错误、6/6 关键控件命中,并再次导出 166 个脱敏节点。 +- `db scan --save`:扫描 4 个进程,发现 17 个数据库,page 1 HMAC 验证 17/17;普通 CLI 输出不含完整密钥。 +- 密钥文件 ACL:继承关闭,仅 `DESKTOP-EGI7QCK\Rogee` 拥有 FullControl。 +- `db query`:只读打开 `contact/contact.db`,运行时报告 SQLCipher `4.5.2 community`、SQLite `3.39.2`,读取 26 个 schema 对象,写入探针被拒绝。 +- 首轮评审后补充验证:WCDB 长十六进制游程使用定长缓冲;不可读/跳过内存区不再拼接候选;密钥临时文件在写入任何密钥字节前即关闭继承并限制到当前用户;重复扫描会合并历史账号/数据库;发现、扫描和 UIA 路径均传播取消;CLI 拒绝未知、重复、缺值和非法超时参数。 +- 验收结束后删除测试密钥文件;未生成解密数据库副本。 + +## 已知风险 + +- `SQLitePCLRaw.bundle_e_sqlcipher` 2.1.11 已弃用且内置 SQLite 3.39.2;它仅获准用于当前开发与真机验证。正式商业发布前必须完成许可/安全基线 gate;若不接受,应改用 Zetetic 官方 SQLCipher for .NET 并重新执行数据库验收。 +- 尚未实现 PID 打开文件句柄与账号根目录的关联,证据等级仅为 `PageHmacVerified`,不能表示账号当前 active。 +- 正在写入的 WAL 数据库可能出现只读一致性/锁问题;本次元数据查询成功,暂不增加解密副本或快照逻辑。 +- M1 尚缺文件传输助手打开、发送/读回、新消息发现和微信重启恢复。 diff --git a/docs/validation/M2-Search-Quote-History-2026-09-04.md b/docs/validation/M2-Search-Quote-History-2026-09-04.md new file mode 100644 index 0000000..6a22e34 --- /dev/null +++ b/docs/validation/M2-Search-Quote-History-2026-09-04.md @@ -0,0 +1,66 @@ +# M2 会话搜索、引用和历史读取验收(2026-09-04) + +## 环境 + +- Windows 10.0.19044,`DESKTOP-EGI7QCK` +- 用户 `Rogee`,Session 1 活动 console +- 微信 4.1.8.29 +- 验收目标仅使用文件传输助手 + +## Linux 验证 + +```bash +dotnet test tests/WxAgent.Core.Tests -c Release +dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true +dotnet publish src/WxAgent.Host -c Release -r win-x64 --self-contained true \ + -p:EnableWindowsTargeting=true -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=false +``` + +结果:Core 测试 13/13 通过;Release 构建 0 warnings、0 errors;Windows 单文件发布及部署成功。 + +## 真机验收 + +### 会话搜索和精确匹配 + +```powershell +WxAgent.Host session search --query 文件传输助手 --exact +WxAgent.Host session open --name 文件传输助手 +``` + +- 搜索退出码 0,返回 1 个结果。 +- 结果名称逐行精确匹配“文件传输助手”,`IsExactMatch=true`;不会把“文件传输助手测试”等部分匹配作为精确结果。 +- 会话打开退出码 0,当前会话确认成功。 +- 搜索框文本使用 UIA ValuePattern 设置并逐字确认,完成后恢复原值。 + +### 真实引用回复与解析 + +```powershell +WxAgent.Host chat reply-latest --text wxagent-quote-<随机标记> --timeout 60 +WxAgent.Host chat read --limit 30 --include-content +``` + +- 使用消息行实时边界右侧的发送方气泡位置打开右键菜单,不维护固定屏幕坐标。 +- 引用回复发送退出码 0,产生新的可见消息。 +- 当前微信可访问名称实测格式:`回复文本\n引用 Rogee 的消息 : 图片`。 +- 解析结果:回复正文精确匹配;引用发送者为 `Rogee`;引用正文为“图片”。 +- CLI 未指定 `--include-content` 时,正文和引用内容均输出为 `null`。 + +### 历史滚动和去重 + +```powershell +WxAgent.Host chat history --limit 100 --scrolls 4 --timeout 60 +``` + +- 在 `chat_message_list` 实时边界中心执行鼠标滚轮向上读取,结束后回滚到原方向。 +- 使用可访问名称和同文本出现序号生成稳定 SHA-256 指纹,避免 UIA RuntimeId 在元素刷新后变化导致重复。 +- 真机结果:退出码 0,读取 7 条消息,7 个唯一指纹,重复数 0。 +- 类型结果:Text 4、File 2、Image 1。 +- 默认输出正文和引用均为空,隐私检查无泄漏。 +- RDP 会话断开时滚轮输入返回 access denied;通过 `doctor`/Session 约束识别,恢复活动 console 后同一命令通过,不绕过桌面会话边界。 + +## 结论 + +本轮目标通过:会话搜索输出及精确匹配、真实引用回复解析、历史滚动读取和有界去重均完成代码、测试、部署和真机验收。 + +尚未宣称整个 M2 里程碑完成:计划中的 500 条连续发送与 100%/125%/150% DPI 总体验收仍属于后续 M2 稳定性工作,不在本轮目标内。 diff --git a/docs/validation/M3-Listening-2026-09-04.md b/docs/validation/M3-Listening-2026-09-04.md new file mode 100644 index 0000000..40f7efb --- /dev/null +++ b/docs/validation/M3-Listening-2026-09-04.md @@ -0,0 +1,99 @@ +# M3 监听和完整消息模型验收(2026-09-04) + +## 环境 + +- Windows 10.0.19044,`DESKTOP-EGI7QCK` +- 用户 `Rogee`,Session 1 活动 console +- 微信 4.1.13.63 +- 验收目标仅使用文件传输助手 +- Windows PowerShell 5.1 验证脚本如包含中文常量,使用 UTF-8 BOM 或 `-EncodedCommand`;无 BOM 的临时脚本会把中文参数显示为乱码,但 Host 原始 UTF-8 JSON 和 Unicode 命令行参数验证为正确。 + +## Linux 验证 + +```bash +dotnet test tests/WxAgent.Core.Tests -c Release +dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true +dotnet publish src/WxAgent.Host -c Release -r win-x64 --self-contained true \ + -p:EnableWindowsTargeting=true -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=false +``` + +结果:Core 测试 15/15 通过;Release 构建 0 warnings、0 errors;Windows 单文件发布及部署成功。 + +最终部署包在聊天页状态执行 `doctor`、`inspect-ui` 和 `smoke`:三项退出码均为 0;doctor 找到全部六个基线控件,smoke 完成文件传输助手文本发送及确认。 + +## 实现范围 + +- `WechatChatClient.ListenEventsAsync` 公开 `IAsyncEnumerable`。 +- UIA 结构事件负责快速唤醒,两秒轮询负责防遗漏;轮询等待使用可取消的单次读取,不遗留后台等待任务。 +- `MessageEventState` 保留最多 4096 个语义指纹;指纹不依赖可能变化的 UIA RuntimeId,同文本消息以出现序号区分。 +- checkpoint 版本为 2,原子写入并限制为当前用户 ACL;Host 重启时产生 `Reconnected` 事件并恢复停机期间可见消息。 +- 可选回调由 `MessageCallbackDispatcher` 逐个执行并隔离异常。 +- 消息类型覆盖 Text、Image、File、Video、Voice、Link、Quote 和 System。 +- `chat monitor` 输出紧凑的逐行 JSON;未指定 `--include-content` 时不输出正文和引用内容。 + +## 真机验收 + +### 实时事件和隐私输出 + +```powershell +WxAgent.Host chat monitor --seconds 30 --state-file artifacts\m3-state.json +WxAgent.Host chat send --text wxagent-m3-<随机标记> +``` + +- checkpoint 建立后发送唯一消息,monitor 输出 1 个 `MessageReceived`。 +- 事件 ID 唯一,重复数 0。 +- 默认输出中 `message.content` 和引用内容均为 `null`。 +- JSON 每个事件严格占一行,可由 `ConvertFrom-Json` 逐行解析。 + +### Host 重启短期续接 + +1. 使用固定 state file 启动并停止 monitor。 +2. monitor 停止期间向文件传输助手发送唯一消息。 +3. 使用相同 state file 再次启动 monitor。 + +结果:先输出 1 个 `Recovered=true` 的 `Reconnected`,随后输出停机期间消息的 `MessageReceived`,该消息同样标记为 recovered,旧消息未重复输出。 + +### RuntimeId 变化后的稳定去重 + +监听期间发送 1 条唯一消息,再通过会话搜索和重新打开文件传输助手刷新 UIA 元素: + +- 观察消息 1,唯一消息 1,重复 0。 +- 已存在的引用消息未因 RuntimeId 变化而重新输出。 + +### 稳定性运行 + +原计划启动 8 小时任务,每约 5 分钟发送一条测试消息;首小时检查时监听器、checkpoint 和事件输出均正常。用户随后明确确认稳定性已满足并取消剩余监听,因此停止任务并清理临时 JSONL、checkpoint 和发送产物。 + +停止时汇总: + +- 发送/预期事件:20 +- 观察事件:20 +- 唯一事件:20 +- 发送失败:0 +- 重复:0 +- 默认正文泄漏:0 +- checkpoint ACL:受保护 + +实际稳定运行约 1.6 小时;未宣称执行满 8 小时,完整时长由用户明确豁免。 + +### 回调异常隔离和类型解析 + +Core 测试验证: + +- 前一回调抛出异常后,后一回调仍执行。 +- 视频、语音、链接、引用和系统消息分类正确。 +- 微信真实引用格式 `回复文本\n引用 <发送者> 的消息 : <原文>` 正确解析。 +- checkpoint 恢复和 4096 条有界去重正确。 + +图片、文件、文本和真实引用消息此前已在同一微信版本的文件传输助手真机验证。 + +## 清理 + +- 已停止并注销临时监听进程。 +- 已删除含运行事件的临时 JSONL、checkpoint、发送结果和 PowerShell 验证脚本。 +- 保留不含正文、联系人或密钥的汇总结果与本验收记录。 + +## 结论 + +M3 功能和本轮用户要求的验证范围通过:实时监听、轮询兜底、完整消息模型、有界稳定去重、回调隔离、Host 重启续接、脱敏 JSONL 和 `IAsyncEnumerable` 均已实现并验证。完整 8 小时持续运行由用户明确取消,不作为本轮完成阻塞。 diff --git a/docs/微信数据库密钥获取流程.md b/docs/微信数据库密钥获取流程.md new file mode 100644 index 0000000..bd5e951 --- /dev/null +++ b/docs/微信数据库密钥获取流程.md @@ -0,0 +1,353 @@ +# 微信数据库密钥获取流程 + +## 适用范围 + +本文记录当前对 Windows 微信 `Weixin.exe 4.1.8.29` 的只读验证结果,以及后续将其接入 `agent-wechat` 的实现要点。 + +## 实测结论 + +- 目标进程:`Weixin.exe` +- 当前版本:`4.1.8.29` +- 发现微信进程:4 个,按工作集内存选择主进程 +- 扫描约 577 MB 可读内存,耗时约 1.5 秒 +- 发现数据库:17 个 +- 找到密钥:17/17 +- 页面 HMAC 校验:17/17 +- SQLite 解密打开验证:17/17 + +测试过程中生成的密钥和解密数据库均已清理。 + +## 方案概览 + +```text +Weixin.exe + -> OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ) + -> VirtualQueryEx 枚举已提交且可读内存 + -> ReadProcessMemory 读取内存段 + -> 扫描 WCDB raw-key 字符串 + -> 通过数据库 page 1 的 salt 筛选候选 + -> HMAC-SHA512 验证候选 key + -> 建立 数据库相对路径 -> enc_key 的映射 + -> 使用 SQLCipher 读取数据库 +``` + +该方案不注入 DLL、不修改微信代码和内存,也不需要解密微信网络流量;但它仍然会申请微信进程的只读句柄并读取进程内存,准确说是低侵入外部内存扫描。 + +## 密钥格式与扫描 + +WCDB 在内存中缓存的候选内容通常表现为十六进制字符串: + +```text +64 hex 字符:32 字节 enc_key +96 hex 字符:32 字节 enc_key + 16 字节 salt +``` + +扫描器还兼容更长的十六进制字符串,并从前 64 个字符取 key、末尾 32 个字符取 salt。 + +扫描区域条件: + +- `MEM_COMMIT` +- 可读保护属性 +- 单个区域小于 500 MB +- 不写入目标进程 + +## 密钥验证 + +从每个 `.db` 文件读取第一个 4096 字节页面: + +```text +page_size = 4096 +key_size = 32 +salt_size = 16 +reserve = 80 +``` + +验证过程: + +1. 取数据库 page 1 的前 16 字节作为 salt。 +2. 对 salt 每字节执行 `byte ^ 0x3A` 得到 HMAC salt。 +3. 使用 PBKDF2-HMAC-SHA512 派生 32 字节 HMAC key,迭代次数为 2。 +4. 对 page 1 的加密数据和页号 1 计算 HMAC-SHA512。 +5. 与页面末尾保存的 HMAC 比较。 + +只有 HMAC 正确的候选才会绑定到该数据库 salt。 + +## 数据库读取 + +解密读取需要 SQLCipher 4 兼容配置,核心参数: + +```sql +PRAGMA key = "x'<64 hex key>'"; +PRAGMA cipher_compatibility = 4; +``` + +验证 SQL: + +```sql +SELECT count(*) FROM sqlite_master; +``` + +数据库分类包括: + +```text +contact/contact.db +session/session.db +message/message_*.db +favorite/favorite.db +emoticon/emoticon.db +media/media_*.db +``` + +## agent-wechat 接入改造点 + +当前 `agent-wechat` 的密钥提取和数据库路径实现偏 Linux: + +```text +/proc//maps +/proc//mem +pgrep +/home/wechat/... +``` + +Windows 版本需要替换为: + +```text +Toolhelp32Snapshot 或 WMI -> 查找 Weixin.exe PID +VirtualQueryEx -> 枚举内存区域 +ReadProcessMemory -> 读取内存 +Documents/AppData 路径扫描 -> 定位 xwechat_files/db_storage +``` + +`wechat-decrypt` 的输出格式是: + +```json +{ + "contact\\contact.db": { + "enc_key": "<64 hex>", + "salt": "<32 hex>" + }, + "_db_dir": "..." +} +``` + +而 `agent-wechat` 当前提取器接口期望类似: + +```json +{ + "keys": { + "contact.db": "<64 hex>" + } +} +``` + +接入时需要增加格式和路径适配层,不能直接替换脚本。 + +## 多账号设计预留 + +密钥不能只保存为一个全局 `key`。正确主键应至少包含: + +```text +account_root / account_id / database_relative_path / salt +``` + +建议保存结构: + +```json +{ + "accounts": { + "": { + "account_root": "", + "wechat_version": "4.1.8.29", + "pid": 1234, + "databases": { + "contact/contact.db": { + "salt": "<32 hex>", + "enc_key": "<64 hex>" + } + } + } + } +} +``` + +不要使用微信昵称、微信号或数据库文件名作为唯一账号标识;它们可能为空、变化或重复。优先使用账号数据库根目录的规范化路径和稳定指纹,并同时保存 salt 用于校验。 + +## 当前机器多账号调查 + +当前机器的微信数据根目录为: + +```text +C:\Users\Rogee\Documents\xwechat_files +``` + +当前观察到: + +```text +xwechat_files/all_users/login/ 1 个历史登录标识 +xwechat_files/*/db_storage/ 1 个账号数据库根目录 +Backup/ 1 个备份目录 +``` + +当前账号数据库目录名类似: + +```text +_\db_storage +``` + +`all_users\login\\key_info.db` 存在 `LoginKeyInfoTable`,包含: + +```text +user_name_md5 +key_md5 +key_info_md5 +key_info_data +``` + +当前机器该表有 30 行,但只有 1 个 distinct user 和 1 个 distinct key。该表不应直接作为数据库解密 key 的唯一来源,仍应以目标账号数据库 page 1 的 salt 和进程内存扫描结果交叉验证。 + +## 多账号密钥存储方案 + +不要使用一个全局 `keys.json`。扫描时应为每个账号数据库根目录单独建立上下文: + +```text +账号上下文 = account_root + Weixin.exe PID + 微信版本 + 数据库相对路径 + salt +``` + +推荐逻辑: + +1. 枚举 `xwechat_files` 下所有包含 `db_storage` 的账号根目录。 +2. 对每个账号根目录收集 `.db` 文件和 page 1 salt。 +3. 扫描所有 `Weixin.exe` 进程内存。 +4. 使用 `key + salt + page 1 HMAC` 将密钥绑定到具体账号根目录。 +5. 每个账号单独保存密钥映射。 +6. 账号退出登录后仍保留历史账号的密钥记录,但标记为 inactive。 +7. 下次登录该账号时,用数据库根目录、salt 和数据库 page 1 重新确认,不依赖 PID。 + +建议内部模型: + +```text +AccountKeySet { + account_root_fingerprint + account_root_path + account_hint + wechat_version + last_seen_at + active + databases { + relative_path + salt + enc_key + verified_at + } +} +``` + +`account_root_fingerprint` 可由以下信息组合计算: + +```text +规范化 account_root 路径 ++ 账号目录名 ++ 数据库文件相对路径集合 ++ page 1 salt 集合 +``` + +不要把微信昵称、微信号或 PID 作为主键:昵称会变化,微信号可能不可见,PID 每次启动都会改变。 + +## 多账号并发注意事项 + +如果多个微信进程同时运行: + +```text +扫描每个 PID + -> 读取该 PID 内存中的候选 key + -> 只与该账号根目录的 salt 集合验证 + -> 成功后绑定 PID 对应账号 +``` + +不能把所有账号的数据库 salt 混在一个全局集合中,否则可能发生错误绑定或重复扫描。 + +如果同一账号同时存在多个微信进程,应以数据库根目录和 salt 作为最终身份,而不是 PID。 + +密钥文件应按账号隔离,并避免在日志中输出完整 `enc_key`。当前方案运行于受控环境,保留明文 `hex_key` 存储,不引入 DPAPI 或 Windows Credential Manager。 + +## 当前登录账号与密钥的绑定 + +密钥本身不携带可直接读取的微信号或昵称。参考 `agent-wechat` 的做法,先确定“当前微信进程对应的数据库根目录”,再把该进程扫描出的密钥绑定到该目录。 + +Linux 版流程: + +```text +WeChat PID + -> 扫描 /proc//fd + -> 找到该进程打开的 db_storage/*.db + -> 截取 xwechat_files/ 后的第一级目录 + -> 得到 account_dir + -> 扫描同一 PID 的内存获取 keys + -> 写入 session_id + account_dir + db_name + hex_key +``` + +对应代码: + +```text +wechat_db.rs::find_account_dir() +login.rs::handle_detecting_user() +wechat_keys.rs::store_keys() +``` + +`agent-wechat` 实际使用的存储主键是: + +```text +(session_id, account_dir, db_name) +``` + +其中: + +- `session_id`:应用管理的会话/实例 +- `account_dir`:微信账号数据库根目录名 +- `db_name`:如 `session.db`、`contact.db`、`message_0.db` +- `hex_key`:对应数据库的密钥 + +Windows 版应使用以下等价流程: + +```text +Weixin.exe PID + -> 获取该 PID 持有的文件句柄 + -> 找到 xwechat_files\\\\db_storage\\*.db + -> 得到 account_dir + -> 只扫描该 PID 的内存 + -> 用该 account_dir 下每个数据库的 page 1 salt 验证 key + -> 写入 (session_id, account_dir, relative_db_path, hex_key) +``` + +Windows 获取进程打开文件的方法按可靠性排序: + +1. `NtQuerySystemInformation(SystemExtendedHandleInformation)` 枚举系统句柄并解析文件对象。 +2. 使用 Sysinternals `handle.exe` 辅助诊断,不作为最终运行依赖。 +3. 通过账号目录、数据库最近写入时间、进程 PID 和密钥 salt 做候选匹配。 + +不能只通过以下信息判断账号: + +```text +密钥值:没有账号语义 +PID:重启后变化 +微信昵称:可能为空或变化 +微信号:可能不可见或变化 +数据库文件名:不同账号相同 +``` + +最可靠的绑定证据是: + +```text +进程 PID 持有的数据库文件路径 ++ 数据库 page 1 salt ++ 候选密钥 HMAC 验证 +``` + +如果同一个微信进程管理多个登录账号,应以每个 `db_storage` 根目录分别扫描和验证;不能把一个 PID 的所有候选 key 无条件归给一个账号。 + +## 参考实现 + +- `ylytdeng/wechat-decrypt/find_all_keys_windows.py` +- `ylytdeng/wechat-decrypt/key_scan_common.py` +- `multi-user-wechat/agent-wechat/packages/agent-server-rust/src/tools/wechat_keys.rs` +- `multi-user-wechat/agent-wechat/packages/agent-server-rust/src/tools/wechat_db.rs` diff --git a/src/WxAgent.Core/ChatMessages.cs b/src/WxAgent.Core/ChatMessages.cs new file mode 100644 index 0000000..e95ae67 --- /dev/null +++ b/src/WxAgent.Core/ChatMessages.cs @@ -0,0 +1,142 @@ +using System.Security.Cryptography; +using System.Text; + +namespace WxAgent.Core; + +public enum ChatMessageType +{ + Text, + Image, + File, + Video, + Voice, + Link, + Quote, + System +} + +public sealed record QuotedMessageSnapshot(string? Sender, string Text); + +public sealed record ChatMessageSource(string Text, string? SourceId = null); + +public sealed record ChatMessageSnapshot( + string Text, + string Fingerprint, + int VisibleIndex, + ChatMessageType Type, + QuotedMessageSnapshot? Quote = null); + +public static class VisibleMessageParser +{ + public static IReadOnlyList Parse(IEnumerable accessibleNames) => + Parse(accessibleNames.Select(text => new ChatMessageSource(text))); + + public static IReadOnlyList Parse(IEnumerable sources) + { + var occurrences = new Dictionary(StringComparer.Ordinal); + var messages = new List(); + foreach (var source in sources.Where(value => !string.IsNullOrWhiteSpace(value.Text))) + { + var text = source.Text; + var occurrence = occurrences.TryGetValue(text, out var count) ? count : 0; + occurrences[text] = occurrence + 1; + var identity = $"{occurrence}:{text}"; + var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity))).ToLowerInvariant(); + var (replyText, quote) = ParseQuote(text); + var type = quote is null ? Classify(replyText) : ChatMessageType.Quote; + messages.Add(new ChatMessageSnapshot(replyText, fingerprint, messages.Count, type, quote)); + } + + return messages; + } + + 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 descriptor = lines[^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)); + } + } + + var quoteIndex = Array.FindIndex(lines, line => + string.Equals(line, "引用", StringComparison.Ordinal) || line.StartsWith("引用:", StringComparison.Ordinal)); + if (quoteIndex < 0 || lines.Length - quoteIndex < 3) + { + return (accessibleName, null); + } + + var marker = lines[quoteIndex]; + var legacySender = marker.Length > 3 ? marker[3..].Trim() : null; + var legacyQuoteText = string.Join('\n', lines[(quoteIndex + 1)..^1]); + return (lines[^1], new QuotedMessageSnapshot(string.IsNullOrWhiteSpace(legacySender) ? null : legacySender, legacyQuoteText)); + } + + public static ChatMessageType Classify(string text) + { + if (text is "[图片]" or "图片" || text.StartsWith("图片\n", StringComparison.Ordinal)) + { + return ChatMessageType.Image; + } + + if (text is "[视频]" or "视频" || text.StartsWith("视频\n", StringComparison.Ordinal)) + { + return ChatMessageType.Video; + } + + if (text is "[语音]" or "语音" || text.StartsWith("语音\n", StringComparison.Ordinal)) + { + return ChatMessageType.Voice; + } + + if (Uri.TryCreate(text, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") + { + return ChatMessageType.Link; + } + + if (text.StartsWith("文件\n", StringComparison.Ordinal)) + { + return ChatMessageType.File; + } + + if (!text.Contains(Path.DirectorySeparatorChar) && !text.Contains(Path.AltDirectorySeparatorChar) && + Path.GetExtension(text) is { Length: > 1 } extension && extension.Length <= 10) + { + return ChatMessageType.File; + } + + return text.StartsWith("[", StringComparison.Ordinal) && text.EndsWith("]", StringComparison.Ordinal) + ? ChatMessageType.System + : ChatMessageType.Text; + } +} + +public sealed class BoundedMessageDeduper(int capacity = 2048) +{ + private readonly Queue _order = new(); + private readonly HashSet _seen = new(StringComparer.Ordinal); + + public bool TryAdd(string fingerprint) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint); + if (!_seen.Add(fingerprint)) + { + return false; + } + + _order.Enqueue(fingerprint); + if (_order.Count > capacity) + { + _seen.Remove(_order.Dequeue()); + } + + return true; + } +} diff --git a/src/WxAgent.Core/DatabaseModels.cs b/src/WxAgent.Core/DatabaseModels.cs new file mode 100644 index 0000000..f6aa7a3 --- /dev/null +++ b/src/WxAgent.Core/DatabaseModels.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography; +using System.Text; + +namespace WxAgent.Core; + +public enum KeyBindingConfidence +{ + PageHmacVerified +} + +public sealed record DatabaseKeyEvidence( + string RelativePath, + string Salt, + string EncKey, + int SourceProcessId, + DateTimeOffset VerifiedAt, + KeyBindingConfidence Confidence); + +public sealed record AccountKeySet( + string AccountRootFingerprint, + string AccountRootPath, + string? WechatVersion, + DateTimeOffset LastSeenAt, + IReadOnlyList Databases); + +public static class AccountKeySetMerge +{ + public static IReadOnlyList Merge( + IReadOnlyList existing, + IReadOnlyList incoming) + { + var accounts = existing.ToDictionary(account => account.AccountRootFingerprint, StringComparer.OrdinalIgnoreCase); + foreach (var current in incoming) + { + if (!accounts.TryGetValue(current.AccountRootFingerprint, out var previous)) + { + accounts[current.AccountRootFingerprint] = current; + continue; + } + + var databases = previous.Databases.ToDictionary(database => database.RelativePath, StringComparer.OrdinalIgnoreCase); + foreach (var database in current.Databases) + { + databases[database.RelativePath] = database; + } + + accounts[current.AccountRootFingerprint] = current with + { + WechatVersion = current.WechatVersion ?? previous.WechatVersion, + Databases = databases.Values.OrderBy(database => database.RelativePath, StringComparer.OrdinalIgnoreCase).ToArray() + }; + } + + return accounts.Values.OrderBy(account => account.AccountRootFingerprint, StringComparer.OrdinalIgnoreCase).ToArray(); + } +} + +public static class AccountRootFingerprint +{ + public static string Create(string accountRootPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(accountRootPath); + var normalized = accountRootPath.Replace('/', '\\').TrimEnd('\\').ToUpperInvariant(); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant(); + } +} diff --git a/src/WxAgent.Core/MessageEvents.cs b/src/WxAgent.Core/MessageEvents.cs new file mode 100644 index 0000000..bef1c65 --- /dev/null +++ b/src/WxAgent.Core/MessageEvents.cs @@ -0,0 +1,113 @@ +namespace WxAgent.Core; + +public enum MessageEventKind +{ + MessageReceived, + Reconnected +} + +public sealed record MessageEvent( + string EventId, + MessageEventKind Kind, + string Session, + ChatMessageSnapshot? Message, + DateTimeOffset ObservedAt, + bool Recovered); + +public sealed record ListenerCheckpoint( + int Version, + string Session, + DateTimeOffset UpdatedAt, + IReadOnlyList Fingerprints); + +public sealed class MessageEventState +{ + private readonly int _capacity; + private readonly Queue _order = new(); + private readonly HashSet _seen = new(StringComparer.Ordinal); + + public MessageEventState(int capacity = 4096, ListenerCheckpoint? checkpoint = null) + { + ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); + _capacity = capacity; + if (checkpoint is null) + { + return; + } + + foreach (var fingerprint in checkpoint.Fingerprints.TakeLast(capacity)) + { + Add(fingerprint); + } + } + + public MessageEvent? TryCreateMessage( + string session, + ChatMessageSnapshot message, + DateTimeOffset observedAt, + bool recovered) + { + ArgumentException.ThrowIfNullOrWhiteSpace(session); + if (!Add(message.Fingerprint)) + { + return null; + } + + return new MessageEvent( + message.Fingerprint, + MessageEventKind.MessageReceived, + session, + message, + observedAt, + recovered); + } + + public ListenerCheckpoint CreateCheckpoint(string session, DateTimeOffset updatedAt) => + new(2, session, updatedAt, _order.ToArray()); + + private bool Add(string fingerprint) + { + if (!_seen.Add(fingerprint)) + { + return false; + } + + _order.Enqueue(fingerprint); + if (_order.Count > _capacity) + { + _seen.Remove(_order.Dequeue()); + } + + return true; + } +} + +public sealed record MessageCallbackFailure(int CallbackIndex, string ErrorType); + +public static class MessageCallbackDispatcher +{ + public static async Task> DispatchAsync( + IReadOnlyList> callbacks, + MessageEvent messageEvent, + CancellationToken cancellationToken) + { + var failures = new List(); + for (var index = 0; index < callbacks.Count; index++) + { + try + { + await callbacks[index](messageEvent, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + failures.Add(new MessageCallbackFailure(index, exception.GetType().Name)); + } + } + + return failures; + } +} diff --git a/src/WxAgent.Core/SqlCipherPageVerifier.cs b/src/WxAgent.Core/SqlCipherPageVerifier.cs new file mode 100644 index 0000000..fa13643 --- /dev/null +++ b/src/WxAgent.Core/SqlCipherPageVerifier.cs @@ -0,0 +1,68 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace WxAgent.Core; + +public static class SqlCipherPageVerifier +{ + public const int PageSize = 4096; + public const int SaltSize = 16; + public const int KeySize = 32; + public const int HmacSize = 64; + + public static bool Verify(ReadOnlySpan page, ReadOnlySpan key, uint pageNumber = 1) + { + if (page.Length != PageSize || key.Length != KeySize || pageNumber == 0) + { + return false; + } + + Span hmacSalt = stackalloc byte[SaltSize]; + for (var index = 0; index < SaltSize; index++) + { + hmacSalt[index] = (byte)(page[index] ^ 0x3a); + } + + Span hmacKey = stackalloc byte[KeySize]; + Rfc2898DeriveBytes.Pbkdf2(key, hmacSalt, hmacKey, 2, HashAlgorithmName.SHA512); + + var authenticatedLength = PageSize - SaltSize - HmacSize; + var authenticated = new byte[authenticatedLength + sizeof(uint)]; + page.Slice(SaltSize, authenticatedLength).CopyTo(authenticated); + BinaryPrimitives.WriteUInt32LittleEndian(authenticated.AsSpan(authenticatedLength), pageNumber); + + var computed = HMACSHA512.HashData(hmacKey, authenticated); + var valid = CryptographicOperations.FixedTimeEquals(computed, page[^HmacSize..]); + CryptographicOperations.ZeroMemory(hmacKey); + CryptographicOperations.ZeroMemory(authenticated); + CryptographicOperations.ZeroMemory(computed); + return valid; + } + + public static bool VerifyHexKey(ReadOnlySpan page, string hexKey, uint pageNumber = 1) + { + if (hexKey.Length != KeySize * 2) + { + return false; + } + + byte[] key; + try + { + key = Convert.FromHexString(hexKey); + } + catch (FormatException) + { + return false; + } + + try + { + return Verify(page, key, pageNumber); + } + finally + { + CryptographicOperations.ZeroMemory(key); + } + } +} diff --git a/src/WxAgent.Core/UiNodeSnapshot.cs b/src/WxAgent.Core/UiNodeSnapshot.cs new file mode 100644 index 0000000..b169b72 --- /dev/null +++ b/src/WxAgent.Core/UiNodeSnapshot.cs @@ -0,0 +1,44 @@ +using System.Security.Cryptography; +using System.Text; + +namespace WxAgent.Core; + +public sealed record UiNodeSnapshot( + string ControlType, + string Name, + string AutomationId, + bool IsEnabled, + string Bounds, + IReadOnlyList Children); + +public static class UiSnapshotSanitizer +{ + private static readonly HashSet SafeAutomationIds = new(StringComparer.Ordinal) + { + "MainView", + "MainView.main_tabbar", + "MainView.main_tabbar.tabbar_setting", + "session_list", + "chat_message_page", + "chat_message_list", + "chat_message_list.qt_scrollarea_viewport.chat_bubble_item_view", + "chat_input_field", + "chat_input_field.qt_scrollarea_viewport", + "tool_bar_accessible" + }; + + public static string SanitizeName(string? value) => Sanitize(value, [], "text"); + + public static string SanitizeAutomationId(string? value) => Sanitize(value, SafeAutomationIds, "id"); + + private static string Sanitize(string? value, HashSet allowList, string label) + { + if (string.IsNullOrEmpty(value) || allowList.Contains(value)) + { + return value ?? string.Empty; + } + + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value)); + return $"[{label}:{value.Length}:{Convert.ToHexString(bytes.AsSpan(0, 4)).ToLowerInvariant()}]"; + } +} diff --git a/src/WxAgent.Core/WcdbKeyCandidateScanner.cs b/src/WxAgent.Core/WcdbKeyCandidateScanner.cs new file mode 100644 index 0000000..1a2d7bd --- /dev/null +++ b/src/WxAgent.Core/WcdbKeyCandidateScanner.cs @@ -0,0 +1,84 @@ +using System.Text; + +namespace WxAgent.Core; + +public sealed record WcdbKeyCandidate(string EncKey, string? SaltHint); + +public sealed class WcdbKeyCandidateScanner +{ + private const int KeyHexLength = 64; + private const int SaltHexLength = 32; + + private readonly StringBuilder _keyPrefix = new(KeyHexLength); + private readonly char[] _saltSuffix = new char[SaltHexLength]; + private readonly HashSet _seen = new(StringComparer.Ordinal); + private long _runLength; + + public IReadOnlyList Feed(ReadOnlySpan bytes, bool finalBlock = false) + { + var found = new List(); + foreach (var value in bytes) + { + if (IsAsciiHex(value)) + { + AppendHex(value); + } + else + { + CompleteRun(found); + } + } + + if (finalBlock) + { + CompleteRun(found); + } + + return found; + } + + private void AppendHex(byte value) + { + var character = char.ToLowerInvariant((char)value); + if (_runLength < KeyHexLength) + { + _keyPrefix.Append(character); + } + + _saltSuffix[_runLength % SaltHexLength] = character; + _runLength++; + } + + private void CompleteRun(List found) + { + if (_runLength >= KeyHexLength) + { + var key = _keyPrefix.ToString(); + var salt = _runLength >= KeyHexLength + SaltHexLength ? GetSaltSuffix() : null; + if (_seen.Add(key + ":" + salt)) + { + found.Add(new WcdbKeyCandidate(key, salt)); + } + } + + _keyPrefix.Clear(); + _runLength = 0; + } + + private string GetSaltSuffix() + { + var salt = new char[SaltHexLength]; + var start = (int)(_runLength % SaltHexLength); + for (var index = 0; index < salt.Length; index++) + { + salt[index] = _saltSuffix[(start + index) % SaltHexLength]; + } + + return new string(salt); + } + + private static bool IsAsciiHex(byte value) => + value is >= (byte)'0' and <= (byte)'9' or + >= (byte)'a' and <= (byte)'f' or + >= (byte)'A' and <= (byte)'F'; +} diff --git a/src/WxAgent.Core/WechatLocators.cs b/src/WxAgent.Core/WechatLocators.cs new file mode 100644 index 0000000..7e3849f --- /dev/null +++ b/src/WxAgent.Core/WechatLocators.cs @@ -0,0 +1,37 @@ +namespace WxAgent.Core; + +public static class WechatLocators +{ + public const string MainView = "MainView"; + public const string SessionList = "session_list"; + public const string ChatPage = "chat_message_page"; + public const string MessageList = "chat_message_list"; + public const string ChatInput = "chat_input_field"; + 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"; + public const string FileTransferAssistant = "文件传输助手"; + public const string Search = "搜索"; + public const string Send = "发送"; + public const string FileTransferAssistantSearchResult = "search_item_function_文件传输助手"; + + public static IReadOnlyList RequiredAutomationIds { get; } = + [MainView, SessionList, ChatPage, MessageList, ChatInput, ToolBar]; + + public static WxAgentErrorCode? ClassifyFoundWindow(IReadOnlyDictionary controls) + { + if (controls.Values.All(found => !found)) + { + return WxAgentErrorCode.WechatNotLoggedIn; + } + + if (!controls.GetValueOrDefault(MainView) || !controls.GetValueOrDefault(SessionList)) + { + return WxAgentErrorCode.UiStructureChanged; + } + + var chatControls = new[] { ChatPage, MessageList, ChatInput, ToolBar }; + var visibleChatControls = chatControls.Count(id => controls.GetValueOrDefault(id)); + return visibleChatControls is 0 or 4 ? null : WxAgentErrorCode.UiStructureChanged; + } +} diff --git a/src/WxAgent.Core/WechatSessions.cs b/src/WxAgent.Core/WechatSessions.cs new file mode 100644 index 0000000..91d8bca --- /dev/null +++ b/src/WxAgent.Core/WechatSessions.cs @@ -0,0 +1,43 @@ +namespace WxAgent.Core; + +public sealed record WechatSessionSnapshot(string Name, string AutomationId, bool IsCurrent); + +public sealed record WechatSessionSearchResult(string Name, string AutomationId, string? Category, bool IsExactMatch); + +public static class WechatSessionParser +{ + public static IReadOnlyList Parse( + IEnumerable<(string AutomationId, string Name)> elements, + string? currentName) + { + return elements + .Where(element => element.AutomationId.StartsWith("session_item_", StringComparison.Ordinal) && + !string.IsNullOrWhiteSpace(element.Name)) + .DistinctBy(element => element.AutomationId, StringComparer.Ordinal) + .Select(element => new WechatSessionSnapshot( + element.Name, + element.AutomationId, + string.Equals(element.Name, currentName, StringComparison.Ordinal))) + .ToArray(); + } + + public static IReadOnlyList ParseSearchResults( + IEnumerable<(string AutomationId, string AccessibleName)> elements, + string query) + { + ArgumentException.ThrowIfNullOrWhiteSpace(query); + return elements + .Where(element => element.AutomationId.StartsWith("search_item_", StringComparison.Ordinal) && + !string.IsNullOrWhiteSpace(element.AccessibleName)) + .DistinctBy(element => element.AutomationId, StringComparer.Ordinal) + .Select(element => + { + var lines = element.AccessibleName.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var exact = lines.FirstOrDefault(line => string.Equals(line, query, StringComparison.Ordinal)); + var name = exact ?? lines.Last(); + var category = lines.Length > 1 && !string.Equals(lines[0], name, StringComparison.Ordinal) ? lines[0] : null; + return new WechatSessionSearchResult(name, element.AutomationId, category, exact is not null); + }) + .ToArray(); + } +} diff --git a/src/WxAgent.Core/WxAgent.Core.csproj b/src/WxAgent.Core/WxAgent.Core.csproj new file mode 100644 index 0000000..bfa77a1 --- /dev/null +++ b/src/WxAgent.Core/WxAgent.Core.csproj @@ -0,0 +1,7 @@ + + + net8.0 + enable + enable + + diff --git a/src/WxAgent.Core/WxAgentErrorCode.cs b/src/WxAgent.Core/WxAgentErrorCode.cs new file mode 100644 index 0000000..945ba98 --- /dev/null +++ b/src/WxAgent.Core/WxAgentErrorCode.cs @@ -0,0 +1,29 @@ +namespace WxAgent.Core; + +public enum WxAgentErrorCode +{ + WechatNotRunning, + WechatNotLoggedIn, + WindowNotFound, + ControlNotFound, + UiStructureChanged, + Timeout, + PermissionMismatch, + SessionLocked, + UnsupportedWechatVersion, + ResultUnconfirmed, + OperationCancelled, + InvalidOperationState, + DataRootNotFound, + ProcessAccessDenied, + DatabaseKeyNotFound, + DatabaseOpenFailed, + ClipboardUnavailable, + InvalidArgument +} + +public sealed class WxAgentException(WxAgentErrorCode code, string message, Exception? innerException = null) + : Exception(message, innerException) +{ + public WxAgentErrorCode Code { get; } = code; +} diff --git a/src/WxAgent.Host/Program.cs b/src/WxAgent.Host/Program.cs new file mode 100644 index 0000000..7fd3f6d --- /dev/null +++ b/src/WxAgent.Host/Program.cs @@ -0,0 +1,469 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using WxAgent.Core; +using WxAgent.Windows; + +var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; +jsonOptions.Converters.Add(new JsonStringEnumConverter()); +var jsonLineOptions = new JsonSerializerOptions(); +jsonLineOptions.Converters.Add(new JsonStringEnumConverter()); +using var shutdown = new CancellationTokenSource(); +Console.CancelKeyPress += (_, eventArgs) => +{ + eventArgs.Cancel = true; + shutdown.Cancel(); +}; + +try +{ + if (args.Length == 0 || args[0] is "help" or "--help" or "-h") + { + PrintHelp(); + return 0; + } + + var defaultTimeoutSeconds = ValidateCommandLine(args); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(shutdown.Token); + timeout.CancelAfter(TimeSpan.FromSeconds(GetTimeoutSeconds(args, defaultTimeoutSeconds))); + var cancellationToken = timeout.Token; + + switch (args[0]) + { + case "doctor": + { + var report = WechatDoctor.Run(cancellationToken); + WriteJson(report); + return report.Errors.Count == 0 ? 0 : 2; + } + case "inspect-ui": + { + var output = GetRequiredOption(args, "--output"); + var snapshot = await WechatUiInspector.CaptureAsync(output, cancellationToken); + WriteJson(new { output = Path.GetFullPath(output), nodes = CountNodes(snapshot), sanitized = true }); + return 0; + } + case "smoke": + { + var output = GetOption(args, "--output") ?? Path.Combine("artifacts", "ui-tree.json"); + var report = WechatDoctor.Run(cancellationToken); + var snapshot = await WechatUiInspector.CaptureAsync(output, cancellationToken); + var marker = $"wxagent-smoke-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"; + var confirmed = await WechatChatClient.SendTextAsync(marker, cancellationToken); + WriteJson(new + { + scope = "M1", + doctorErrors = report.Errors, + output = Path.GetFullPath(output), + nodes = CountNodes(snapshot), + sanitized = true, + sentTo = "file-transfer-assistant", + confirmed.Fingerprint, + confirmed = true + }); + return report.Errors.Count == 0 ? 0 : 2; + } + case "chat" when args[1] == "send": + { + var message = await WechatChatClient.SendTextAsync(GetRequiredOption(args, "--text"), cancellationToken); + WriteJson(ToMessageOutput(message, includeContent: false)); + return 0; + } + case "chat" when args[1] == "reply-latest": + { + var message = await WechatChatClient.ReplyToLatestAsync(GetRequiredOption(args, "--text"), cancellationToken); + WriteJson(ToMessageOutput(message, includeContent: false)); + return 0; + } + case "chat" when args[1] is "send-file" or "send-image": + { + var path = GetRequiredOption(args, "--path"); + 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); + WriteJson(ToMessageOutput(message, includeContent: false)); + return 0; + } + case "chat" when args[1] == "read": + { + var includeContent = HasOption(args, "--include-content"); + var limit = GetPositiveIntOption(args, "--limit", 20, 100); + var messages = await WechatChatClient.ReadVisibleAsync(cancellationToken); + WriteJson(new { messages = messages.TakeLast(limit).Select(message => ToMessageOutput(message, includeContent)) }); + return 0; + } + case "chat" when args[1] == "history": + { + var includeContent = HasOption(args, "--include-content"); + var limit = GetPositiveIntOption(args, "--limit", 100, 1000); + var scrolls = GetPositiveIntOption(args, "--scrolls", 10, 100, allowZero: true); + var messages = await WechatChatClient.ReadHistoryAsync(limit, scrolls, cancellationToken); + WriteJson(new { messages = messages.Select(message => ToMessageOutput(message, includeContent)) }); + return 0; + } + case "chat" when args[1] == "monitor": + { + var includeContent = HasOption(args, "--include-content"); + var seconds = GetPositiveIntOption(args, "--seconds", 60, 86400); + var stateFile = GetOption(args, "--state-file"); + await foreach (var messageEvent in WechatChatClient.ListenEventsAsync( + TimeSpan.FromSeconds(seconds), + stateFile, + cancellationToken)) + { + Console.WriteLine(JsonSerializer.Serialize(ToEventOutput(messageEvent, includeContent), jsonLineOptions)); + } + return 0; + } + case "chat" when args[1] == "listen": + { + var includeContent = HasOption(args, "--include-content"); + var seconds = GetPositiveIntOption(args, "--seconds", 30, 300); + var messages = new List(); + await foreach (var message in WechatChatClient.ListenAsync(TimeSpan.FromSeconds(seconds), cancellationToken)) + { + messages.Add(message); + } + + WriteJson(new { durationSeconds = seconds, messages = messages.Select(message => ToMessageOutput(message, includeContent)) }); + return 0; + } + case "session" when args[1] == "list": + { + WriteJson(new { sessions = await WechatChatClient.ListVisibleSessionsAsync(cancellationToken) }); + return 0; + } + case "session" when args[1] == "search": + { + var query = GetRequiredOption(args, "--query"); + var results = await WechatChatClient.SearchSessionsAsync(query, HasOption(args, "--exact"), cancellationToken); + WriteJson(new { query, results }); + return 0; + } + case "session" when args[1] == "current": + { + WriteJson(new { session = await WechatChatClient.GetCurrentSessionAsync(cancellationToken) }); + return 0; + } + case "session" when args[1] == "open": + { + WriteJson(await WechatChatClient.OpenSessionAsync(GetRequiredOption(args, "--name"), cancellationToken)); + return 0; + } + case "db" when args[1] == "scan": + { + var dataRoot = GetOption(args, "--data-root"); + var result = WechatDatabaseScanner.Scan(dataRoot, cancellationToken); + string? savedTo = null; + if (HasOption(args, "--save")) + { + savedTo = GetOption(args, "--key-file") ?? DatabaseKeyStore.DefaultPath; + var existing = await DatabaseKeyStore.LoadAsync(savedTo, cancellationToken); + var merged = AccountKeySetMerge.Merge(existing, result.Accounts); + await DatabaseKeyStore.SaveAsync(merged, savedTo, cancellationToken); + } + + WriteJson(new + { + result.ProcessCount, + result.CandidateCount, + result.DatabaseCount, + verifiedDatabaseCount = result.Accounts.Sum(account => account.Databases.Count), + savedTo, + accounts = result.Accounts.Select(account => new + { + account.AccountRootFingerprint, + account.WechatVersion, + databases = account.Databases.Select(database => new + { + database.RelativePath, + database.SourceProcessId, + database.Confidence, + database.VerifiedAt + }) + }) + }); + return result.Accounts.Any(account => account.Databases.Count > 0) ? 0 : 3; + } + case "db" when args[1] == "status": + { + var keyFile = GetOption(args, "--key-file"); + var accounts = await DatabaseKeyStore.LoadAsync(keyFile, cancellationToken); + WriteJson(new + { + keyFile = Path.GetFullPath(keyFile ?? DatabaseKeyStore.DefaultPath), + accounts = accounts.Select(account => new + { + account.AccountRootFingerprint, + account.WechatVersion, + databaseCount = account.Databases.Count, + databases = account.Databases.Select(database => new { database.RelativePath, database.Confidence, database.VerifiedAt }) + }) + }); + return 0; + } + case "db" when args[1] == "query": + { + var accountId = GetRequiredOption(args, "--account"); + var relativePath = GetRequiredOption(args, "--database").Replace('\\', '/'); + var keyFile = GetOption(args, "--key-file"); + var accounts = await DatabaseKeyStore.LoadAsync(keyFile, cancellationToken); + var account = accounts.SingleOrDefault(item => string.Equals(item.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase)) + ?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "Account fingerprint was not found in the key store."); + var database = account.Databases.SingleOrDefault(item => string.Equals(item.RelativePath, relativePath, StringComparison.OrdinalIgnoreCase)) + ?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "Database path was not found for the selected account."); + var fullPath = Path.GetFullPath(Path.Combine(account.AccountRootPath, database.RelativePath.Replace('/', Path.DirectorySeparatorChar))); + var accountRoot = Path.GetFullPath(account.AccountRootPath) + Path.DirectorySeparatorChar; + if (!fullPath.StartsWith(accountRoot, StringComparison.OrdinalIgnoreCase)) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Database path escapes the account root."); + } + + var metadata = await SqlCipherDatabaseReader.ReadMetadataAsync(fullPath, database.EncKey, cancellationToken); + WriteJson(new { account = account.AccountRootFingerprint, database = database.RelativePath, metadata }); + return 0; + } + default: + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown command. Run WxAgent.Host help."); + } +} +catch (OperationCanceledException) when (!shutdown.IsCancellationRequested) +{ + WriteJson(new { error = WxAgentErrorCode.Timeout.ToString(), message = "Operation timed out." }); + return 124; +} +catch (OperationCanceledException) +{ + WriteJson(new { error = WxAgentErrorCode.OperationCancelled.ToString(), message = "Operation cancelled." }); + return 130; +} +catch (WxAgentException exception) +{ + WriteJson(new { error = exception.Code.ToString(), exception.Message }); + return 1; +} +catch (Exception exception) +{ + WriteJson(new { error = WxAgentErrorCode.InvalidOperationState.ToString(), message = exception.Message }); + return 1; +} + +void WriteJson(T value) => Console.WriteLine(JsonSerializer.Serialize(value, jsonOptions)); + +static int ValidateCommandLine(string[] values) +{ + if (values[0] == "doctor") + { + ValidateOptions(values, 1, ["--timeout"], []); + return 30; + } + + if (values[0] == "inspect-ui") + { + ValidateOptions(values, 1, ["--output", "--timeout"], []); + return 30; + } + + if (values[0] == "smoke") + { + ValidateOptions(values, 1, ["--output", "--timeout"], []); + return 30; + } + + if (values[0] == "chat" && values.Length >= 2) + { + switch (values[1]) + { + case "send": + ValidateOptions(values, 2, ["--text", "--timeout"], []); + return 30; + case "reply-latest": + ValidateOptions(values, 2, ["--text", "--timeout"], []); + return 60; + case "send-file": + case "send-image": + ValidateOptions(values, 2, ["--path", "--timeout"], []); + return 60; + case "read": + ValidateOptions(values, 2, ["--limit", "--timeout"], ["--include-content"]); + return 30; + case "history": + ValidateOptions(values, 2, ["--limit", "--scrolls", "--timeout"], ["--include-content"]); + return 60; + case "monitor": + ValidateOptions(values, 2, ["--seconds", "--state-file", "--timeout"], ["--include-content"]); + return GetPositiveIntOption(values, "--seconds", 60, 86400) + 30; + case "listen": + ValidateOptions(values, 2, ["--seconds", "--timeout"], ["--include-content"]); + return GetPositiveIntOption(values, "--seconds", 30, 300) + 10; + default: + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown chat command. Run WxAgent.Host help."); + } + } + + if (values[0] == "session" && values.Length >= 2) + { + switch (values[1]) + { + case "list": + case "current": + ValidateOptions(values, 2, ["--timeout"], []); + return 30; + case "search": + ValidateOptions(values, 2, ["--query", "--timeout"], ["--exact"]); + return 30; + case "open": + ValidateOptions(values, 2, ["--name", "--timeout"], []); + return 30; + default: + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown session command. Run WxAgent.Host help."); + } + } + + if (values[0] != "db" || values.Length < 2) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown command. Run WxAgent.Host help."); + } + + switch (values[1]) + { + case "scan": + ValidateOptions(values, 2, ["--data-root", "--key-file", "--timeout"], ["--save"]); + return 120; + case "status": + ValidateOptions(values, 2, ["--key-file", "--timeout"], []); + return 30; + case "query": + ValidateOptions(values, 2, ["--account", "--database", "--key-file", "--timeout"], []); + return 30; + default: + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown database command. Run WxAgent.Host help."); + } +} + +static void ValidateOptions(string[] values, int startIndex, HashSet valueOptions, HashSet flagOptions) +{ + var seen = new HashSet(StringComparer.Ordinal); + for (var index = startIndex; index < values.Length; index++) + { + var option = values[index]; + if (!seen.Add(option)) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Duplicate option {option}."); + } + + if (flagOptions.Contains(option)) + { + continue; + } + + if (!valueOptions.Contains(option)) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Unknown argument {option}."); + } + + if (++index >= values.Length || values[index].StartsWith("--", StringComparison.Ordinal)) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Missing value for {option}."); + } + } +} + +static string? GetOption(string[] values, string name) +{ + var index = Array.IndexOf(values, name); + return index >= 0 && index + 1 < values.Length && !values[index + 1].StartsWith("--", StringComparison.Ordinal) + ? values[index + 1] + : null; +} + +static string GetRequiredOption(string[] values, string name) => + GetOption(values, name) ?? throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Missing required option {name}."); + +static bool HasOption(string[] values, string name) => Array.IndexOf(values, name) >= 0; + +static int GetTimeoutSeconds(string[] values, int fallback) +{ + var raw = GetOption(values, "--timeout"); + if (raw is null) + { + return fallback; + } + + if (!int.TryParse(raw, out var parsed) || parsed <= 0) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "--timeout must be a positive whole number of seconds."); + } + + return parsed; +} + +static int GetPositiveIntOption(string[] values, string name, int fallback, int maximum, bool allowZero = false) +{ + var raw = GetOption(values, name); + if (raw is null) + { + return fallback; + } + + var minimum = allowZero ? 0 : 1; + if (!int.TryParse(raw, out var parsed) || parsed < minimum || parsed > maximum) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"{name} must be between {minimum} and {maximum}."); + } + + return parsed; +} + +static bool IsImagePath(string path) => new[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp" } + .Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase); + +static object ToEventOutput(MessageEvent messageEvent, bool includeContent) => new +{ + messageEvent.EventId, + messageEvent.Kind, + messageEvent.Session, + messageEvent.ObservedAt, + messageEvent.Recovered, + message = messageEvent.Message is null ? null : ToMessageOutput(messageEvent.Message, includeContent) +}; + +static object ToMessageOutput(ChatMessageSnapshot message, bool includeContent) => new +{ + message.Fingerprint, + message.Type, + length = message.Text.Length, + content = includeContent ? message.Text : null, + quote = includeContent ? message.Quote : null +}; + +static int CountNodes(UiNodeSnapshot node) => 1 + node.Children.Sum(CountNodes); + +static void PrintHelp() => Console.WriteLine(""" +WxAgent.Host commands: + doctor [--timeout 30] + inspect-ui --output [--timeout 30] + smoke [--output ] [--timeout 30] + chat send --text [--timeout 30] + chat reply-latest --text [--timeout 60] + chat send-file --path [--timeout 60] + chat send-image --path [--timeout 60] + chat read [--limit 20] [--include-content] [--timeout 30] + chat history [--limit 100] [--scrolls 10] [--include-content] [--timeout 60] + chat monitor [--seconds 60] [--state-file ] [--include-content] [--timeout ] + chat listen [--seconds 30] [--include-content] [--timeout ] + session list [--timeout 30] + session search --query [--exact] [--timeout 30] + session current [--timeout 30] + session open --name [--timeout 30] + db scan [--data-root ] [--save] [--key-file ] [--timeout 120] + db status [--key-file ] [--timeout 30] + db query --account --database [--key-file ] [--timeout 30] + +Chat commands are restricted to File Transfer Assistant. Message content is omitted unless --include-content is explicit. +Database keys are never printed. db scan only saves verified keys when --save is present. +"""); diff --git a/src/WxAgent.Host/WxAgent.Host.csproj b/src/WxAgent.Host/WxAgent.Host.csproj new file mode 100644 index 0000000..f51954a --- /dev/null +++ b/src/WxAgent.Host/WxAgent.Host.csproj @@ -0,0 +1,19 @@ + + + Exe + net8.0-windows10.0.19041.0 + true + win-x64 + x64 + enable + enable + true + true + true + false + WxAgent.Host + + + + + diff --git a/src/WxAgent.Windows/DatabaseKeyStore.cs b/src/WxAgent.Windows/DatabaseKeyStore.cs new file mode 100644 index 0000000..bd69daa --- /dev/null +++ b/src/WxAgent.Windows/DatabaseKeyStore.cs @@ -0,0 +1,79 @@ +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text.Json; +using System.Text.Json.Serialization; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public static class DatabaseKeyStore +{ + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + public static string DefaultPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WxAgent", "database-keys.json"); + + public static async Task SaveAsync(IReadOnlyList accounts, string? path, CancellationToken cancellationToken) + { + var fullPath = Path.GetFullPath(path ?? DefaultPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + var temporary = fullPath + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + await using (new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + } + + RestrictToCurrentUser(temporary); + await using (var stream = new FileStream(temporary, FileMode.Truncate, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await JsonSerializer.SerializeAsync(stream, accounts, JsonOptions, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + + File.Move(temporary, fullPath, true); + RestrictToCurrentUser(fullPath); + } + finally + { + if (File.Exists(temporary)) + { + File.Delete(temporary); + } + } + } + + public static async Task> LoadAsync(string? path, CancellationToken cancellationToken) + { + var fullPath = Path.GetFullPath(path ?? DefaultPath); + cancellationToken.ThrowIfCancellationRequested(); + if (!File.Exists(fullPath)) + { + return []; + } + + await using var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous); + return await JsonSerializer.DeserializeAsync>(stream, JsonOptions, cancellationToken) ?? []; + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions { WriteIndented = true }; + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + internal static void RestrictToCurrentUser(string path) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var identity = WindowsIdentity.GetCurrent().User ?? throw new InvalidOperationException("Current Windows user SID is unavailable."); + var security = new FileSecurity(); + security.SetOwner(identity); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule(identity, FileSystemRights.FullControl, AccessControlType.Allow)); + new FileInfo(path).SetAccessControl(security); + } +} diff --git a/src/WxAgent.Windows/ListenerCheckpointStore.cs b/src/WxAgent.Windows/ListenerCheckpointStore.cs new file mode 100644 index 0000000..c1d11d8 --- /dev/null +++ b/src/WxAgent.Windows/ListenerCheckpointStore.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public static class ListenerCheckpointStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + + public static string DefaultPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "WxAgent", + "listener-checkpoint.json"); + + public static async Task LoadAsync(string? path, CancellationToken cancellationToken) + { + var fullPath = Path.GetFullPath(path ?? DefaultPath); + if (!File.Exists(fullPath)) + { + return null; + } + + await using var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous); + var checkpoint = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken) + ?? throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener checkpoint is empty or invalid."); + return checkpoint.Version == 2 ? checkpoint : null; + } + + public static async Task SaveAsync(ListenerCheckpoint checkpoint, string? path, CancellationToken cancellationToken) + { + var fullPath = Path.GetFullPath(path ?? DefaultPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + var temporary = fullPath + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + await using (new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + } + DatabaseKeyStore.RestrictToCurrentUser(temporary); + await using (var stream = new FileStream(temporary, FileMode.Truncate, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await JsonSerializer.SerializeAsync(stream, checkpoint, JsonOptions, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + File.Move(temporary, fullPath, true); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } +} diff --git a/src/WxAgent.Windows/NativeMethods.txt b/src/WxAgent.Windows/NativeMethods.txt new file mode 100644 index 0000000..e618e2a --- /dev/null +++ b/src/WxAgent.Windows/NativeMethods.txt @@ -0,0 +1,5 @@ +OpenProcess +CloseHandle +VirtualQueryEx +ReadProcessMemory +QueryFullProcessImageName diff --git a/src/WxAgent.Windows/ProcessMemoryScanner.cs b/src/WxAgent.Windows/ProcessMemoryScanner.cs new file mode 100644 index 0000000..e6b6779 --- /dev/null +++ b/src/WxAgent.Windows/ProcessMemoryScanner.cs @@ -0,0 +1,158 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.System.Memory; +using Windows.Win32.System.Threading; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public sealed record ProcessAccessProbe(bool Success, string? Error); + +public static unsafe class ProcessMemoryScanner +{ + private const int ChunkSize = 1024 * 1024; + private const ulong MaximumRegionSize = 500UL * 1024 * 1024; + private const ulong MaximumUserAddress = 0x00007FFFFFFEFFFF; + + public static ProcessAccessProbe Probe(int processId) + { + var process = PInvoke.OpenProcess(PROCESS_ACCESS_RIGHTS.PROCESS_QUERY_INFORMATION | PROCESS_ACCESS_RIGHTS.PROCESS_VM_READ, false, (uint)processId); + if (process.IsNull) + { + return new ProcessAccessProbe(false, new Win32Exception(Marshal.GetLastWin32Error()).Message); + } + + PInvoke.CloseHandle(process); + return new ProcessAccessProbe(true, null); + } + + public static IReadOnlyList Scan(int processId, CancellationToken cancellationToken) + { + var process = PInvoke.OpenProcess(PROCESS_ACCESS_RIGHTS.PROCESS_QUERY_INFORMATION | PROCESS_ACCESS_RIGHTS.PROCESS_VM_READ, false, (uint)processId); + if (process.IsNull) + { + throw new WxAgentException(WxAgentErrorCode.ProcessAccessDenied, $"Cannot read Weixin process {processId}.", new Win32Exception(Marshal.GetLastWin32Error())); + } + + try + { + return Scan(process, cancellationToken); + } + finally + { + PInvoke.CloseHandle(process); + } + } + + private static IReadOnlyList Scan(HANDLE process, CancellationToken cancellationToken) + { + var scanner = new WcdbKeyCandidateScanner(); + var found = new List(); + ulong? contiguousEnd = null; + ulong address = 0; + while (address < MaximumUserAddress) + { + cancellationToken.ThrowIfCancellationRequested(); + MEMORY_BASIC_INFORMATION region; + if (PInvoke.VirtualQueryEx(process, (void*)address, ®ion, (nuint)sizeof(MEMORY_BASIC_INFORMATION)) == 0) + { + break; + } + + var baseAddress = (ulong)region.BaseAddress; + var regionSize = (ulong)region.RegionSize; + if (regionSize == 0 || baseAddress > ulong.MaxValue - regionSize) + { + break; + } + + var next = baseAddress + regionSize; + if (next <= address) + { + break; + } + + if (!IsReadable(region) || regionSize > MaximumRegionSize) + { + BreakRun(scanner, found); + contiguousEnd = null; + } + else + { + if (contiguousEnd != baseAddress) + { + BreakRun(scanner, found); + } + + contiguousEnd = ReadRegion(process, baseAddress, regionSize, scanner, found, cancellationToken) + ? next + : null; + } + + address = next; + } + + BreakRun(scanner, found); + return found; + } + + private static bool ReadRegion( + HANDLE process, + ulong baseAddress, + ulong regionSize, + WcdbKeyCandidateScanner scanner, + List found, + CancellationToken cancellationToken) + { + var tailIsContiguous = false; + var offset = 0UL; + while (offset < regionSize) + { + cancellationToken.ThrowIfCancellationRequested(); + var count = (int)Math.Min((ulong)ChunkSize, regionSize - offset); + var buffer = GC.AllocateUninitializedArray(count); + nuint bytesRead = 0; + bool success; + fixed (byte* destination = buffer) + { + success = PInvoke.ReadProcessMemory(process, (void*)(baseAddress + offset), destination, (nuint)count, &bytesRead); + } + + if (bytesRead > 0) + { + var length = checked((int)Math.Min(bytesRead, (nuint)count)); + found.AddRange(scanner.Feed(buffer.AsSpan(0, length))); + } + + if (!success || bytesRead != (nuint)count) + { + BreakRun(scanner, found); + tailIsContiguous = false; + } + else + { + tailIsContiguous = true; + } + + offset += (ulong)count; + } + + return tailIsContiguous; + } + + private static void BreakRun(WcdbKeyCandidateScanner scanner, List found) => + found.AddRange(scanner.Feed([], finalBlock: true)); + + private static bool IsReadable(MEMORY_BASIC_INFORMATION region) + { + const uint memCommit = 0x1000; + const uint pageNoAccess = 0x01; + const uint pageGuard = 0x100; + const uint readableMask = 0x02 | 0x04 | 0x08 | 0x20 | 0x40 | 0x80; + var state = (uint)region.State; + var protection = (uint)region.Protect; + return state == memCommit && (protection & (pageNoAccess | pageGuard)) == 0 && (protection & readableMask) != 0; + } +} diff --git a/src/WxAgent.Windows/SqlCipherDatabaseReader.cs b/src/WxAgent.Windows/SqlCipherDatabaseReader.cs new file mode 100644 index 0000000..a4d6268 --- /dev/null +++ b/src/WxAgent.Windows/SqlCipherDatabaseReader.cs @@ -0,0 +1,118 @@ +using Microsoft.Data.Sqlite; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public sealed record DatabaseMetadata(string CipherVersion, string SqliteVersion, long SchemaObjectCount, IReadOnlyList SchemaObjects, bool WritesRejected); + +public static class SqlCipherDatabaseReader +{ + private static readonly Lazy Initialized = new(() => + { + SQLitePCL.Batteries_V2.Init(); + return true; + }); + + public static async Task ReadMetadataAsync(string databasePath, string hexKey, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(databasePath); + if (hexKey.Length != 64 || !hexKey.All(Uri.IsHexDigit)) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Database key must be exactly 64 hexadecimal characters."); + } + + _ = Initialized.Value; + try + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = Path.GetFullPath(databasePath), + Mode = SqliteOpenMode.ReadOnly, + Cache = SqliteCacheMode.Private, + Pooling = false, + DefaultTimeout = 10 + }.ToString(); + + await using var connection = new SqliteConnection(connectionString); + await connection.OpenAsync(cancellationToken); + await using (var command = connection.CreateCommand()) + { + command.CommandText = $"PRAGMA key = \"x'{hexKey}'\";"; + command.CommandTimeout = 10; + await command.ExecuteNonQueryAsync(cancellationToken); + } + + await using (var command = connection.CreateCommand()) + { + command.CommandText = "PRAGMA cipher_compatibility = 4; PRAGMA query_only = ON;"; + command.CommandTimeout = 10; + await command.ExecuteNonQueryAsync(cancellationToken); + } + + var cipherVersion = await ReadCipherVersionAsync(connection, cancellationToken); + var sqliteVersion = await ReadSqliteVersionAsync(connection, cancellationToken); + var count = await ReadSchemaCountAsync(connection, cancellationToken); + var names = new List(); + await using (var command = connection.CreateCommand()) + { + command.CommandText = "SELECT name FROM sqlite_master WHERE name IS NOT NULL ORDER BY name LIMIT 100;"; + command.CommandTimeout = 10; + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var name = reader.GetString(0); + names.Add(name.Length <= 256 ? name : name[..256]); + } + } + + var writesRejected = false; + try + { + var probeName = "__wxagent_read_only_probe_" + Guid.NewGuid().ToString("N"); + await using var command = connection.CreateCommand(); + command.CommandText = $"CREATE TABLE {probeName}(value INTEGER);"; + command.CommandTimeout = 10; + await command.ExecuteNonQueryAsync(cancellationToken); + } + catch (SqliteException exception) when (exception.SqliteErrorCode == 8) + { + writesRejected = true; + } + + if (!writesRejected) + { + throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "Read-only SQLCipher connection unexpectedly accepted a write."); + } + + return new DatabaseMetadata(cipherVersion, sqliteVersion, count, names, writesRejected); + } + catch (SqliteException exception) + { + throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "SQLCipher could not open or read the selected database in read-only mode.", exception); + } + } + + private static async Task ReadCipherVersionAsync(SqliteConnection connection, CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = "PRAGMA cipher_version;"; + command.CommandTimeout = 10; + return Convert.ToString(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + } + + private static async Task ReadSqliteVersionAsync(SqliteConnection connection, CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT sqlite_version();"; + command.CommandTimeout = 10; + return Convert.ToString(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + } + + private static async Task ReadSchemaCountAsync(SqliteConnection connection, CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT count(*) FROM sqlite_master;"; + command.CommandTimeout = 10; + return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/src/WxAgent.Windows/WechatChatClient.cs b/src/WxAgent.Windows/WechatChatClient.cs new file mode 100644 index 0000000..1fdf650 --- /dev/null +++ b/src/WxAgent.Windows/WechatChatClient.cs @@ -0,0 +1,889 @@ +using System.Collections.Specialized; +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using System.Windows.Forms; +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; +using FlaUI.Core.Input; +using FlaUI.Core.WindowsAPI; +using FlaUI.UIA3; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public static class WechatChatClient +{ + private static readonly SemaphoreSlim CommandQueue = new(1, 1); + + public static Task> ListVisibleSessionsAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using var automation = new UIA3Automation(); + var window = AttachWindow(automation); + var list = FindByAutomationId(window, WechatLocators.SessionList) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.SessionList} was not found."); + var current = SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)); + var sessions = WechatSessionParser.Parse( + list.FindAllDescendants() + .Where(element => SafeControlType(element) == ControlType.ListItem) + .Select(element => (SafeAutomationId(element), SafeName(element))), + current); + return Task.FromResult>(sessions); + } + + public static async Task> SearchSessionsAsync( + string query, + bool exactOnly, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(query); + await CommandQueue.WaitAsync(cancellationToken); + try + { + using var automation = new UIA3Automation(); + var window = AttachWindow(automation); + var search = window.FindAllDescendants().FirstOrDefault(element => + SafeControlType(element) == ControlType.Edit && SafeName(element) == WechatLocators.Search) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat search box was not found."); + var box = search.AsTextBox(); + var previous = box.Text; + try + { + box.Text = query; + if (!string.Equals(box.Text, query, StringComparison.Ordinal)) + { + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The search box did not contain the exact query."); + } + + await Task.Delay(500, cancellationToken); + var results = WechatSessionParser.ParseSearchResults( + window.FindAllDescendants() + .Where(element => SafeControlType(element) == ControlType.ListItem) + .Select(element => (SafeAutomationId(element), SafeName(element))), + query); + return exactOnly ? results.Where(result => result.IsExactMatch).ToArray() : results; + } + finally + { + box.Text = previous; + } + } + finally + { + CommandQueue.Release(); + } + } + + public static Task GetCurrentSessionAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using var automation = new UIA3Automation(); + var window = AttachWindow(automation); + var element = FindByAutomationId(window, WechatLocators.CurrentChatName); + var name = SafeName(element); + return Task.FromResult(string.IsNullOrWhiteSpace(name) + ? null + : new WechatSessionSnapshot(name, SafeAutomationId(element!), true)); + } + + public static async Task OpenSessionAsync(string name, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + await CommandQueue.WaitAsync(cancellationToken); + try + { + using var automation = new UIA3Automation(); + var window = AttachWindow(automation); + if (SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)) == name) + { + return new WechatSessionSnapshot(name, SafeAutomationId(FindByAutomationId(window, WechatLocators.CurrentChatName)!), true); + } + + var target = FindByAutomationId(window, "session_item_" + name); + if (target is null) + { + var search = window.FindAllDescendants().FirstOrDefault(element => + SafeControlType(element) == ControlType.Edit && SafeName(element) == WechatLocators.Search) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat search box was not found."); + search.AsTextBox().Text = name; + var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5); + while (DateTimeOffset.UtcNow < deadline && target is null) + { + cancellationToken.ThrowIfCancellationRequested(); + target = window.FindAllDescendants().FirstOrDefault(element => + SafeAutomationId(element).StartsWith("search_item_", StringComparison.Ordinal) && SessionNameMatches(SafeName(element), name)); + if (target is null) await Task.Delay(150, cancellationToken); + } + } + + if (target is null) + { + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The requested session was not found."); + } + + ClickCenter(target); + if (!await WaitForSessionAsync(window, name, TimeSpan.FromSeconds(10), cancellationToken)) + { + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested session did not become active."); + } + + return new WechatSessionSnapshot(name, SafeAutomationId(target), true); + } + finally + { + CommandQueue.Release(); + } + } + + public static async Task SendTextAsync(string text, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(text); + 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 + { + using var automation = new UIA3Automation(); + var window = AttachWindow(automation); + await OpenFileTransferAssistantCoreAsync(window, cancellationToken); + await Task.Delay(500, cancellationToken); + + var input = FindByAutomationId(window, WechatLocators.ChatInput) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found."); + var send = window.FindAllDescendants().FirstOrDefault(element => + SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found."); + + var inputBox = input.AsTextBox(); + ExecuteInputStep("set-input-value", () => inputBox.Text = text); + await Task.Delay(150, cancellationToken); + if (!string.Equals(inputBox.Text, text, StringComparison.Ordinal)) + { + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The chat input did not contain the exact requested text; send was aborted."); + } + + ExecuteInputStep("click-send", () => ClickCenter(send)); + + var confirmed = await WaitForMessageAsync(text, TimeSpan.FromSeconds(20), cancellationToken); + return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The text was entered but no matching visible message confirmed the send result."); + } + finally + { + CommandQueue.Release(); + } + } + + public static async Task ReplyToLatestAsync(string text, CancellationToken cancellationToken) + { + 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."); + } + + await CommandQueue.WaitAsync(cancellationToken); + try + { + using var automation = new UIA3Automation(); + var window = AttachWindow(automation); + await OpenFileTransferAssistantCoreAsync(window, cancellationToken); + 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 latest = list.FindAllDescendants() + .LastOrDefault(element => SafeAutomationId(element) == WechatLocators.ChatBubbleItem) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "No visible message is available to quote."); + RightClickMessageBubble(latest); + + AutomationElement? quote = null; + var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(30); + while (DateTimeOffset.UtcNow < deadline && quote is null) + { + cancellationToken.ThrowIfCancellationRequested(); + quote = automation.GetDesktop().FindAllDescendants().FirstOrDefault(element => + SessionNameMatches(SafeName(element), "引用") && element.BoundingRectangle.Width > 0); + if (quote is null) await Task.Delay(100, cancellationToken); + } + if (quote is null) + { + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The quote context-menu item was not found."); + } + + ClickCenter(quote); + await Task.Delay(300, cancellationToken); + 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)) + { + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The reply input did not contain the exact requested text; send was aborted."); + } + + var send = window.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(before, TimeSpan.FromSeconds(30), cancellationToken); + return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "No new visible quoted reply confirmed the send result."); + } + finally + { + CommandQueue.Release(); + } + } + + public static Task SendFileAsync(string path, CancellationToken cancellationToken) => + SendAttachmentAsync(path, asImage: false, cancellationToken); + + public static Task SendImageAsync(string path, CancellationToken cancellationToken) => + SendAttachmentAsync(path, asImage: true, cancellationToken); + + private static async Task SendAttachmentAsync(string path, bool asImage, CancellationToken cancellationToken) + { + var fullPath = Path.GetFullPath(path); + if (!File.Exists(fullPath)) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The attachment file does not exist."); + } + + await CommandQueue.WaitAsync(cancellationToken); + try + { + using var automation = new UIA3Automation(); + var window = AttachWindow(automation); + await OpenFileTransferAssistantCoreAsync(window, cancellationToken); + 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)) + { + 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)) + { + 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."); + 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."); + } + finally + { + CommandQueue.Release(); + } + } + + public static async Task> ReadHistoryAsync( + int maxMessages, + int maxScrolls, + CancellationToken cancellationToken) + { + if (maxMessages is < 1 or > 1000 || maxScrolls is < 0 or > 100) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "History limits are maxMessages 1-1000 and maxScrolls 0-100."); + } + + await CommandQueue.WaitAsync(cancellationToken); + try + { + using var automation = new UIA3Automation(); + var window = AttachWindow(automation); + await OpenFileTransferAssistantCoreAsync(window, cancellationToken); + var messages = new List(); + var deduper = new BoundedMessageDeduper(Math.Max(2048, maxMessages * 4)); + var unchanged = 0; + var scrolled = 0; + for (var page = 0; page <= maxScrolls; page++) + { + cancellationToken.ThrowIfCancellationRequested(); + window = WechatDoctor.FindWechatWindow(automation) ?? window; + var visible = ReadVisible(window); + var older = visible.Where(message => deduper.TryAdd(message.Fingerprint)).ToArray(); + if (older.Length == 0) unchanged++; else unchanged = 0; + if (page == 0) messages.AddRange(older); else messages.InsertRange(0, older); + if (messages.Count >= maxMessages || unchanged >= 2 || page == maxScrolls) break; + + var list = FindByAutomationId(window, WechatLocators.MessageList) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found."); + MoveToCenter(list); + Mouse.Scroll(5); + scrolled++; + await Task.Delay(350, cancellationToken); + } + + if (scrolled > 0) + { + Mouse.Scroll(-5d * scrolled); + } + + return messages.Count <= maxMessages ? messages : messages.TakeLast(maxMessages).ToArray(); + } + finally + { + CommandQueue.Release(); + } + } + + public static async Task> ReadVisibleAsync(CancellationToken cancellationToken) + { + await CommandQueue.WaitAsync(cancellationToken); + try + { + using var automation = new UIA3Automation(); + var window = AttachWindow(automation); + await OpenFileTransferAssistantCoreAsync(window, cancellationToken); + return ReadVisible(window); + } + finally + { + CommandQueue.Release(); + } + } + + public static async IAsyncEnumerable ListenAsync( + TimeSpan duration, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (duration <= TimeSpan.Zero) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Listen duration must be positive."); + } + + using var automation = new UIA3Automation(); + AutomationElement window; + await CommandQueue.WaitAsync(cancellationToken); + try + { + window = AttachWindow(automation); + await OpenFileTransferAssistantCoreAsync(window, cancellationToken); + } + finally + { + CommandQueue.Release(); + } + + var list = FindByAutomationId(window, WechatLocators.MessageList) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found."); + var signals = Channel.CreateBounded(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite }); + using var subscription = list.RegisterStructureChangedEvent( + TreeScope.Subtree, + (_, _, _) => signals.Writer.TryWrite(true)); + var deduper = new BoundedMessageDeduper(); + foreach (var message in ReadVisible(window)) + { + deduper.TryAdd(message.Fingerprint); + } + + var deadline = DateTimeOffset.UtcNow + duration; + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + var remaining = deadline - DateTimeOffset.UtcNow; + var delay = Task.Delay(remaining < TimeSpan.FromSeconds(2) ? remaining : TimeSpan.FromSeconds(2), cancellationToken); + var eventReady = signals.Reader.WaitToReadAsync(cancellationToken).AsTask(); + await Task.WhenAny(delay, eventReady); + while (signals.Reader.TryRead(out _)) + { + } + + foreach (var message in ReadVisible(window)) + { + if (deduper.TryAdd(message.Fingerprint)) + { + yield return message; + } + } + } + } + + public static async IAsyncEnumerable ListenEventsAsync( + TimeSpan duration, + string? checkpointPath, + [EnumeratorCancellation] CancellationToken cancellationToken, + IReadOnlyList>? callbacks = null) + { + if (duration <= TimeSpan.Zero) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Listen duration must be positive."); + } + + const string session = WechatLocators.FileTransferAssistant; + var checkpoint = await ListenerCheckpointStore.LoadAsync(checkpointPath, cancellationToken); + if (checkpoint is not null && !string.Equals(checkpoint.Session, session, StringComparison.Ordinal)) + { + checkpoint = null; + } + + var state = new MessageEventState(checkpoint: checkpoint); + var recoveredScan = checkpoint is not null; + var signals = Channel.CreateBounded(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite }); + using var automation = new UIA3Automation(); + AutomationElement? window = null; + IDisposable? subscription = null; + var connected = false; + var announceReconnect = recoveredScan; + var elapsed = System.Diagnostics.Stopwatch.StartNew(); + + try + { + while (elapsed.Elapsed < duration) + { + cancellationToken.ThrowIfCancellationRequested(); + IReadOnlyList? visible = null; + try + { + window = WechatDoctor.FindWechatWindow(automation); + if (window is not null) + { + if (!connected) + { + await CommandQueue.WaitAsync(cancellationToken); + try + { + await OpenFileTransferAssistantCoreAsync(window, cancellationToken); + } + finally + { + CommandQueue.Release(); + } + + subscription?.Dispose(); + var list = FindByAutomationId(window, WechatLocators.MessageList); + subscription = list?.RegisterStructureChangedEvent( + TreeScope.Subtree, + (_, _, _) => signals.Writer.TryWrite(true)); + connected = true; + } + visible = ReadVisible(window); + } + else + { + connected = false; + } + } + catch (OperationCanceledException) + { + throw; + } + catch + { + connected = false; + subscription?.Dispose(); + subscription = null; + } + + if (connected && announceReconnect) + { + announceReconnect = false; + var reconnectEvent = new MessageEvent( + "reconnected-" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + MessageEventKind.Reconnected, + session, + null, + DateTimeOffset.UtcNow, + Recovered: true); + if (callbacks is { Count: > 0 }) + { + await MessageCallbackDispatcher.DispatchAsync(callbacks, reconnectEvent, cancellationToken); + } + yield return reconnectEvent; + } + + var changed = false; + if (visible is not null) + { + if (checkpoint is null && !recoveredScan) + { + foreach (var message in visible) + { + state.TryCreateMessage(session, message, DateTimeOffset.UtcNow, recovered: false); + } + changed = true; + recoveredScan = false; + } + else + { + foreach (var message in visible) + { + var messageEvent = state.TryCreateMessage(session, message, DateTimeOffset.UtcNow, recoveredScan); + if (messageEvent is not null) + { + changed = true; + if (callbacks is { Count: > 0 }) + { + await MessageCallbackDispatcher.DispatchAsync(callbacks, messageEvent, cancellationToken); + } + yield return messageEvent; + } + } + recoveredScan = false; + } + } + + if (changed) + { + await ListenerCheckpointStore.SaveAsync( + state.CreateCheckpoint(session, DateTimeOffset.UtcNow), + checkpointPath, + cancellationToken); + checkpoint ??= state.CreateCheckpoint(session, DateTimeOffset.UtcNow); + } + + if (!connected) + { + announceReconnect = true; + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + continue; + } + + var remaining = duration - elapsed.Elapsed; + if (remaining <= TimeSpan.Zero) + { + break; + } + + using var pollCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + pollCancellation.CancelAfter(remaining < TimeSpan.FromSeconds(2) ? remaining : TimeSpan.FromSeconds(2)); + try + { + await signals.Reader.WaitToReadAsync(pollCancellation.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + } + while (signals.Reader.TryRead(out _)) + { + } + } + } + finally + { + subscription?.Dispose(); + } + } + + private static AutomationElement AttachWindow(UIA3Automation automation) + { + var window = WechatDoctor.FindWechatWindow(automation); + if (window is null) + { + throw new WxAgentException(WxAgentErrorCode.WindowNotFound, "WeChat main window was not found in the current interactive session."); + } + + window.Focus(); + return window; + } + + private static async Task OpenFileTransferAssistantCoreAsync(AutomationElement window, CancellationToken cancellationToken) + { + if (IsFileTransferAssistantOpen(window)) + { + return; + } + + var sessionItem = FindByAutomationId(window, "session_item_" + WechatLocators.FileTransferAssistant); + if (sessionItem is not null) + { + ClickCenter(sessionItem); + if (await WaitForFileTransferAssistantAsync(window, TimeSpan.FromSeconds(5), cancellationToken)) + { + return; + } + } + + var search = window.FindAllDescendants().FirstOrDefault(element => + SafeControlType(element) == ControlType.Edit && SafeName(element) == WechatLocators.Search) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat search box was not found."); + var searchBox = search.AsTextBox(); + ExecuteInputStep("set-search-value", () => searchBox.Text = WechatLocators.FileTransferAssistant); + + var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5); + AutomationElement? searchResult = null; + while (DateTimeOffset.UtcNow < deadline && searchResult is null) + { + cancellationToken.ThrowIfCancellationRequested(); + searchResult = FindByAutomationId(window, WechatLocators.FileTransferAssistantSearchResult); + if (searchResult is null) + { + await Task.Delay(150, cancellationToken); + } + } + + if (searchResult is null) + { + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "File Transfer Assistant search result was not found."); + } + + ClickCenter(searchResult); + if (!await WaitForFileTransferAssistantAsync(window, TimeSpan.FromSeconds(10), cancellationToken)) + { + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "File Transfer Assistant could not be activated from search results."); + } + } + + private static bool IsFileTransferAssistantOpen(AutomationElement window) => + SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)) == WechatLocators.FileTransferAssistant; + + private static Task WaitForFileTransferAssistantAsync( + AutomationElement window, + TimeSpan timeout, + CancellationToken cancellationToken) => + WaitForSessionAsync(window, WechatLocators.FileTransferAssistant, timeout, cancellationToken); + + private static async Task WaitForSessionAsync( + AutomationElement window, + string name, + TimeSpan timeout, + CancellationToken cancellationToken) + { + var deadline = DateTimeOffset.UtcNow + timeout; + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + if (SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)) == name) + { + return true; + } + + await Task.Delay(150, cancellationToken); + } + + return false; + } + + private static async Task WaitForNewMessageAsync( + HashSet previous, + TimeSpan timeout, + CancellationToken cancellationToken) + { + var deadline = DateTimeOffset.UtcNow + timeout; + while (DateTimeOffset.UtcNow < deadline) + { + 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 (message is not null) return message; + await Task.Delay(250, cancellationToken); + } + + return null; + } + + private static async Task WaitForMessageAsync( + string text, + TimeSpan timeout, + CancellationToken cancellationToken) + { + var deadline = DateTimeOffset.UtcNow + timeout; + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + using var freshAutomation = new UIA3Automation(); + var window = WechatDoctor.FindWechatWindow(freshAutomation); + var match = window is null + ? null + : ReadVisible(window).LastOrDefault(message => string.Equals(message.Text, text, StringComparison.Ordinal)); + if (match is not null) + { + return match; + } + + await Task.Delay(150, cancellationToken); + } + + return null; + } + + private static IReadOnlyList ReadVisible(AutomationElement window) + { + var list = FindByAutomationId(window, WechatLocators.MessageList) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found."); + return VisibleMessageParser.Parse(list.FindAllDescendants() + .Where(element => SafeAutomationId(element) == WechatLocators.ChatBubbleItem) + .Select(element => new ChatMessageSource(SafeName(element), SafeRuntimeId(element)))); + } + + private static AutomationElement? FindByAutomationId(AutomationElement root, string automationId) => + root.FindFirstDescendant(condition => condition.ByAutomationId(automationId)); + + private sealed class ClipboardFileLease : IDisposable + { + private readonly ManualResetEventSlim _ready = new(); + private readonly ManualResetEventSlim _restore = new(); + private readonly Thread _thread; + private Exception? _error; + + public ClipboardFileLease(string fullPath, bool asImage) + { + _thread = new Thread(() => Run(fullPath, asImage)) { IsBackground = true }; + _thread.SetApartmentState(ApartmentState.STA); + _thread.Start(); + if (!_ready.Wait(TimeSpan.FromSeconds(5))) + { + throw new WxAgentException(WxAgentErrorCode.ClipboardUnavailable, "Timed out while preparing the Windows clipboard."); + } + ThrowIfFailed(); + } + + public void Dispose() + { + _restore.Set(); + if (!_thread.Join(TimeSpan.FromSeconds(5))) + { + throw new WxAgentException(WxAgentErrorCode.ClipboardUnavailable, "Timed out while restoring the Windows clipboard."); + } + ThrowIfFailed(); + _ready.Dispose(); + _restore.Dispose(); + } + + private void Run(string fullPath, bool asImage) + { + string? text = null; + StringCollection? files = null; + System.Drawing.Image? image = null; + System.Drawing.Image? outgoingImage = null; + try + { + 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) + { + using var source = System.Drawing.Image.FromFile(fullPath); + outgoingImage = new System.Drawing.Bitmap(source); + Clipboard.SetImage(outgoingImage); + } + else + { + Clipboard.SetFileDropList(new StringCollection { fullPath }); + } + } + catch (Exception exception) + { + _error = exception; + _ready.Set(); + return; + } + + _ready.Set(); + _restore.Wait(); + try + { + if (files is { Count: > 0 }) Clipboard.SetFileDropList(files); + else if (image is not null) Clipboard.SetImage(image); + else if (text is not null) Clipboard.SetText(text); + else Clipboard.Clear(); + } + catch (Exception exception) + { + _error = exception; + } + finally + { + image?.Dispose(); + outgoingImage?.Dispose(); + } + } + + private void ThrowIfFailed() + { + if (_error is not null) + { + throw new WxAgentException(WxAgentErrorCode.ClipboardUnavailable, "The Windows clipboard could not be used for attachment sending.", _error); + } + } + } + + private static void MoveToCenter(AutomationElement element) + { + var bounds = element.BoundingRectangle; + if (bounds.Width <= 0 || bounds.Height <= 0) + { + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The target control has no usable bounds."); + } + + Mouse.MoveTo(new System.Drawing.Point(bounds.Left + bounds.Width / 2, bounds.Top + bounds.Height / 2)); + } + + private static void RightClickMessageBubble(AutomationElement element) + { + var bounds = element.BoundingRectangle; + if (bounds.Width <= 160 || bounds.Height <= 0) + { + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The target message has no usable bounds."); + } + + // WeChat message rows span the viewport; own-message bubbles are anchored at the right edge. + Mouse.RightClick(new System.Drawing.Point(bounds.Right - 80, bounds.Top + bounds.Height / 2)); + } + + private static void ClickCenter(AutomationElement element) + { + var bounds = element.BoundingRectangle; + if (bounds.Width <= 0 || bounds.Height <= 0) + { + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The target control has no clickable bounds."); + } + + Mouse.Click(new System.Drawing.Point(bounds.Left + bounds.Width / 2, bounds.Top + bounds.Height / 2), MouseButton.Left); + } + + private static void ExecuteInputStep(string step, Action action) + { + try + { + action(); + } + catch (Exception exception) + { + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, $"UI input failed at {step}.", exception); + } + } + + private static bool SessionNameMatches(string accessibleName, string requestedName) => + accessibleName.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Contains(requestedName, StringComparer.Ordinal); + + private static string SafeName(AutomationElement? element) + { + if (element is null) + { + return string.Empty; + } + + try { return element.Name ?? string.Empty; } catch { return string.Empty; } + } + + private static string SafeAutomationId(AutomationElement element) + { + try { return element.AutomationId ?? string.Empty; } catch { return string.Empty; } + } + + private static string? SafeRuntimeId(AutomationElement element) + { + try { return string.Join('.', element.FrameworkAutomationElement.RuntimeId); } catch { return null; } + } + + private static ControlType SafeControlType(AutomationElement element) + { + try { return element.ControlType; } catch { return ControlType.Custom; } + } +} diff --git a/src/WxAgent.Windows/WechatDatabaseDiscovery.cs b/src/WxAgent.Windows/WechatDatabaseDiscovery.cs new file mode 100644 index 0000000..b163979 --- /dev/null +++ b/src/WxAgent.Windows/WechatDatabaseDiscovery.cs @@ -0,0 +1,58 @@ +using WxAgent.Core; + +namespace WxAgent.Windows; + +public sealed record AccountDatabaseFile(string FullPath, string RelativePath, byte[] FirstPage, string Salt); + +public sealed record AccountDatabaseRoot(string AccountRootPath, string Fingerprint, IReadOnlyList Databases); + +public static class WechatDatabaseDiscovery +{ + public static IReadOnlyList FindAccountRoots( + string? xwechatFilesRoot = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var root = xwechatFilesRoot ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "xwechat_files"); + if (!Directory.Exists(root)) + { + return []; + } + + var accounts = new List(); + foreach (var accountDirectory in Directory.EnumerateDirectories(root)) + { + cancellationToken.ThrowIfCancellationRequested(); + var dbStorage = Path.Combine(accountDirectory, "db_storage"); + if (!Directory.Exists(dbStorage)) + { + continue; + } + + var databases = new List(); + foreach (var path in Directory.EnumerateFiles(dbStorage, "*.db", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var page = new byte[SqlCipherPageVerifier.PageSize]; + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + stream.ReadExactly(page); + + var relativePath = Path.GetRelativePath(dbStorage, path).Replace('\\', '/'); + databases.Add(new AccountDatabaseFile(path, relativePath, page, Convert.ToHexString(page.AsSpan(0, SqlCipherPageVerifier.SaltSize)).ToLowerInvariant())); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + accounts.Add(new AccountDatabaseRoot(dbStorage, AccountRootFingerprint.Create(dbStorage), databases)); + } + + return accounts; + } +} diff --git a/src/WxAgent.Windows/WechatDatabaseScanner.cs b/src/WxAgent.Windows/WechatDatabaseScanner.cs new file mode 100644 index 0000000..72b940c --- /dev/null +++ b/src/WxAgent.Windows/WechatDatabaseScanner.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public sealed record DatabaseScanResult(IReadOnlyList Accounts, int ProcessCount, int CandidateCount, int DatabaseCount); + +public static class WechatDatabaseScanner +{ + public static DatabaseScanResult Scan(string? xwechatFilesRoot, CancellationToken cancellationToken) + { + var accounts = WechatDatabaseDiscovery.FindAccountRoots(xwechatFilesRoot, cancellationToken); + if (accounts.Count == 0) + { + throw new WxAgentException(WxAgentErrorCode.DataRootNotFound, "No xwechat_files account db_storage directory was found."); + } + + var processes = Process.GetProcessesByName("Weixin").OrderByDescending(process => process.WorkingSet64).ToArray(); + if (processes.Length == 0) + { + throw new WxAgentException(WxAgentErrorCode.WechatNotRunning, "Weixin.exe is not running."); + } + + var evidence = accounts.ToDictionary(account => account.Fingerprint, _ => new Dictionary(StringComparer.OrdinalIgnoreCase)); + var candidateCount = 0; + var scannedProcessCount = 0; + string? version = null; + foreach (var process in processes) + { + cancellationToken.ThrowIfCancellationRequested(); + IReadOnlyList candidates; + try + { + candidates = ProcessMemoryScanner.Scan(process.Id, cancellationToken); + scannedProcessCount++; + version ??= SafeVersion(process); + } + catch (WxAgentException exception) when (exception.Code == WxAgentErrorCode.ProcessAccessDenied) + { + continue; + } + + candidateCount += candidates.Count; + foreach (var candidate in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + foreach (var account in accounts) + { + cancellationToken.ThrowIfCancellationRequested(); + foreach (var database in account.Databases) + { + cancellationToken.ThrowIfCancellationRequested(); + if (evidence[account.Fingerprint].ContainsKey(database.RelativePath) || + candidate.SaltHint is not null && !string.Equals(candidate.SaltHint, database.Salt, StringComparison.OrdinalIgnoreCase) || + !SqlCipherPageVerifier.VerifyHexKey(database.FirstPage, candidate.EncKey)) + { + continue; + } + + evidence[account.Fingerprint][database.RelativePath] = new DatabaseKeyEvidence( + database.RelativePath, + database.Salt, + candidate.EncKey, + process.Id, + DateTimeOffset.UtcNow, + KeyBindingConfidence.PageHmacVerified); + } + } + } + } + + if (scannedProcessCount == 0) + { + throw new WxAgentException(WxAgentErrorCode.ProcessAccessDenied, "No Weixin process could be opened for read-only memory access."); + } + + var results = accounts.Select(account => new AccountKeySet( + account.Fingerprint, + account.AccountRootPath, + version, + DateTimeOffset.UtcNow, + evidence[account.Fingerprint].Values.OrderBy(item => item.RelativePath, StringComparer.OrdinalIgnoreCase).ToArray())).ToArray(); + + return new DatabaseScanResult(results, processes.Length, candidateCount, accounts.Sum(account => account.Databases.Count)); + } + + private static string? SafeVersion(Process process) + { + try { return process.MainModule?.FileVersionInfo.FileVersion; } catch { return null; } + } +} diff --git a/src/WxAgent.Windows/WechatDoctor.cs b/src/WxAgent.Windows/WechatDoctor.cs new file mode 100644 index 0000000..02d318a --- /dev/null +++ b/src/WxAgent.Windows/WechatDoctor.cs @@ -0,0 +1,113 @@ +using System.Diagnostics; +using FlaUI.Core; +using FlaUI.Core.Definitions; +using FlaUI.UIA3; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public sealed record ProcessDiagnostic(int Id, long WorkingSetBytes, int SessionId, string? Version, bool CanReadMemory, string? AccessError); + +public sealed record DoctorReport( + string OperatingSystem, + bool UserInteractive, + int CurrentSessionId, + IReadOnlyList Processes, + bool WindowFound, + IReadOnlyDictionary RequiredControls, + int DataRootCount, + IReadOnlyList Errors); + +public static class WechatDoctor +{ + public static DoctorReport Run(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var errors = new List(); + var processes = Process.GetProcessesByName("Weixin") + .OrderByDescending(process => process.WorkingSet64) + .Select(process => CreateProcessDiagnostic(process, cancellationToken)) + .ToArray(); + + if (processes.Length == 0) + { + errors.Add(WxAgentErrorCode.WechatNotRunning); + } + else if (processes.All(process => !process.CanReadMemory)) + { + errors.Add(WxAgentErrorCode.ProcessAccessDenied); + } + + var controls = WechatLocators.RequiredAutomationIds.ToDictionary(id => id, _ => false, StringComparer.Ordinal); + var windowFound = false; + try + { + using var automation = new UIA3Automation(); + var window = FindWechatWindow(automation); + windowFound = window is not null; + if (window is not null) + { + foreach (var id in controls.Keys.ToArray()) + { + cancellationToken.ThrowIfCancellationRequested(); + controls[id] = window.FindFirstDescendant(condition => condition.ByAutomationId(id)) is not null; + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch + { + windowFound = false; + } + + if (!windowFound && processes.Length > 0) + { + errors.Add(processes.Any(process => process.SessionId != Process.GetCurrentProcess().SessionId) + ? WxAgentErrorCode.SessionLocked + : WxAgentErrorCode.WindowNotFound); + } + else if (windowFound && WechatLocators.ClassifyFoundWindow(controls) is { } uiError) + { + errors.Add(uiError); + } + + var dataRootCount = WechatDatabaseDiscovery.FindAccountRoots(cancellationToken: cancellationToken).Count; + if (dataRootCount == 0) + { + errors.Add(WxAgentErrorCode.DataRootNotFound); + } + + return new DoctorReport( + Environment.OSVersion.VersionString, + Environment.UserInteractive, + Process.GetCurrentProcess().SessionId, + processes, + windowFound, + controls, + dataRootCount, + errors.Distinct().ToArray()); + } + + internal static FlaUI.Core.AutomationElements.AutomationElement? FindWechatWindow(AutomationBase automation) => + automation.GetDesktop().FindAllChildren(condition => condition.ByControlType(ControlType.Window)) + .FirstOrDefault(element => string.Equals(element.Name, "微信", StringComparison.Ordinal)); + + private static ProcessDiagnostic CreateProcessDiagnostic(Process process, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + string? version = null; + try + { + version = process.MainModule?.FileVersionInfo.FileVersion; + } + catch + { + } + + var access = ProcessMemoryScanner.Probe(process.Id); + return new ProcessDiagnostic(process.Id, process.WorkingSet64, process.SessionId, version, access.Success, access.Error); + } +} diff --git a/src/WxAgent.Windows/WechatUiInspector.cs b/src/WxAgent.Windows/WechatUiInspector.cs new file mode 100644 index 0000000..3a2f31a --- /dev/null +++ b/src/WxAgent.Windows/WechatUiInspector.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using FlaUI.Core.AutomationElements; +using FlaUI.UIA3; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public static class WechatUiInspector +{ + public static async Task CaptureAsync(string outputPath, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + cancellationToken.ThrowIfCancellationRequested(); + + using var automation = new UIA3Automation(); + var window = WechatDoctor.FindWechatWindow(automation) + ?? throw new WxAgentException(WxAgentErrorCode.WindowNotFound, "WeChat window was not found in the current interactive session."); + + var snapshot = Capture(window, cancellationToken); + var fullPath = Path.GetFullPath(outputPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + await using var stream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous); + await JsonSerializer.SerializeAsync(stream, snapshot, new JsonSerializerOptions { WriteIndented = true }, cancellationToken); + return snapshot; + } + + private static UiNodeSnapshot Capture(AutomationElement element, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var children = new List(); + AutomationElement[] rawChildren; + try + { + rawChildren = element.FindAllChildren(); + } + catch + { + rawChildren = []; + } + + foreach (var child in rawChildren) + { + children.Add(Capture(child, cancellationToken)); + } + + string bounds; + try + { + var rectangle = element.BoundingRectangle; + bounds = $"{rectangle.Left:0},{rectangle.Top:0},{rectangle.Width:0},{rectangle.Height:0}"; + } + catch + { + bounds = string.Empty; + } + + return new UiNodeSnapshot( + element.ControlType.ToString(), + UiSnapshotSanitizer.SanitizeName(SafeRead(() => element.Name)), + UiSnapshotSanitizer.SanitizeAutomationId(SafeRead(() => element.AutomationId)), + SafeRead(() => element.IsEnabled, false), + bounds, + children); + } + + private static string SafeRead(Func read) + { + try { return read() ?? string.Empty; } catch { return string.Empty; } + } + + private static T SafeRead(Func read, T fallback) + { + try { return read(); } catch { return fallback; } + } +} diff --git a/src/WxAgent.Windows/WxAgent.Windows.csproj b/src/WxAgent.Windows/WxAgent.Windows.csproj new file mode 100644 index 0000000..5205903 --- /dev/null +++ b/src/WxAgent.Windows/WxAgent.Windows.csproj @@ -0,0 +1,20 @@ + + + net8.0-windows10.0.19041.0 + true + win-x64 + x64 + enable + enable + true + false + true + + + + + + + + + diff --git a/tests/WxAgent.Core.Tests/CoreTests.cs b/tests/WxAgent.Core.Tests/CoreTests.cs new file mode 100644 index 0000000..fb9ff77 --- /dev/null +++ b/tests/WxAgent.Core.Tests/CoreTests.cs @@ -0,0 +1,295 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WcdbKeyCandidateScannerTests +{ + [Fact] + public void ParsesChunkSplitsLengthsDuplicatesAndBoundaries() + { + var key = string.Concat(Enumerable.Repeat("01", 32)); + var salt = string.Concat(Enumerable.Repeat("ab", 16)); + var longRun = key + "cafe" + salt; + var bytes = System.Text.Encoding.ASCII.GetBytes("z" + new string('f', 63) + "!" + key + " " + key + salt + "-" + longRun + "."); + var scanner = new WcdbKeyCandidateScanner(); + + var found = new List(); + found.AddRange(scanner.Feed(bytes.AsSpan(0, 31))); + found.AddRange(scanner.Feed(bytes.AsSpan(31, 80))); + found.AddRange(scanner.Feed(bytes.AsSpan(111), finalBlock: true)); + + Assert.Equal(2, found.Count); + Assert.Contains(found, candidate => candidate.EncKey == key && candidate.SaltHint is null); + Assert.Single(found, candidate => candidate.EncKey == key && candidate.SaltHint == salt); + } + + [Fact] + public void BoundsLongRunsAndDoesNotJoinAcrossExplicitGaps() + { + var key = string.Concat(Enumerable.Repeat("01", 32)); + var salt = string.Concat(Enumerable.Repeat("ab", 16)); + var scanner = new WcdbKeyCandidateScanner(); + var longRun = System.Text.Encoding.ASCII.GetBytes(key + new string('c', 1_000_000) + salt); + + var found = new List(); + for (var offset = 0; offset < longRun.Length; offset += 4093) + { + found.AddRange(scanner.Feed(longRun.AsSpan(offset, Math.Min(4093, longRun.Length - offset)))); + } + found.AddRange(scanner.Feed([], finalBlock: true)); + + var candidate = Assert.Single(found); + Assert.Equal(key, candidate.EncKey); + Assert.Equal(salt, candidate.SaltHint); + + var gapScanner = new WcdbKeyCandidateScanner(); + Assert.Empty(gapScanner.Feed(System.Text.Encoding.ASCII.GetBytes(key[..40]))); + Assert.Empty(gapScanner.Feed([], finalBlock: true)); + Assert.Empty(gapScanner.Feed(System.Text.Encoding.ASCII.GetBytes(key[40..]), finalBlock: true)); + } +} + +public sealed class SqlCipherPageVerifierTests +{ + private const string ExpectedMac = "61b73efa9571819b9efd9a8424bd0e3882bb2c35baf5603262a05c630fab78a7c35ffc4041b589a09c9ed8df25457a131608bbfe67af87ebcba5268c8ddee055"; + + [Fact] + public void MatchesIndependentKnownAnswerAndRejectsMutations() + { + var key = Enumerable.Range(0, 32).Select(value => (byte)value).ToArray(); + var page = CreateKnownAnswerPage(); + + Assert.True(SqlCipherPageVerifier.Verify(page, key)); + Assert.True(SqlCipherPageVerifier.VerifyHexKey(page, Convert.ToHexString(key))); + + AssertRejected(page, key, 0); + AssertRejected(page, key, 16); + AssertRejected(page, key, page.Length - 1); + + var wrongKey = key.ToArray(); + wrongKey[0] ^= 1; + Assert.False(SqlCipherPageVerifier.Verify(page, wrongKey)); + Assert.False(SqlCipherPageVerifier.Verify(page, key, 2)); + } + + private static byte[] CreateKnownAnswerPage() + { + var page = new byte[SqlCipherPageVerifier.PageSize]; + for (var index = 0; index < SqlCipherPageVerifier.SaltSize; index++) + { + page[index] = (byte)(index + 16); + } + + for (var index = SqlCipherPageVerifier.SaltSize; index < page.Length - SqlCipherPageVerifier.HmacSize; index++) + { + page[index] = (byte)(index * 31 + 7); + } + + Convert.FromHexString(ExpectedMac).CopyTo(page, page.Length - SqlCipherPageVerifier.HmacSize); + return page; + } + + private static void AssertRejected(byte[] page, byte[] key, int index) + { + var changed = page.ToArray(); + changed[index] ^= 1; + Assert.False(SqlCipherPageVerifier.Verify(changed, key)); + } +} + +public sealed class ChatMessageTests +{ + [Fact] + public void ParsesDuplicateVisibleMessagesWithStableDistinctFingerprints() + { + var first = VisibleMessageParser.Parse(["same", "same", "other"]); + var second = VisibleMessageParser.Parse(["same", "same", "other"]); + + Assert.Equal(3, first.Count); + Assert.Equal(3, first.Select(message => message.Fingerprint).Distinct().Count()); + Assert.Equal(first.Select(message => message.Fingerprint), second.Select(message => message.Fingerprint)); + } + + [Fact] + public void ClassifiesBasicVisibleMessageTypes() + { + Assert.Equal(ChatMessageType.Text, VisibleMessageParser.Classify("hello")); + Assert.Equal(ChatMessageType.Image, VisibleMessageParser.Classify("[图片]")); + Assert.Equal(ChatMessageType.File, VisibleMessageParser.Classify("report.pdf")); + Assert.Equal(ChatMessageType.Video, VisibleMessageParser.Classify("[视频]")); + Assert.Equal(ChatMessageType.Voice, VisibleMessageParser.Classify("语音\n3秒")); + Assert.Equal(ChatMessageType.Link, VisibleMessageParser.Classify("https://example.com")); + Assert.Equal(ChatMessageType.System, VisibleMessageParser.Classify("[系统消息]")); + } + + [Fact] + public void ParsesQuotedReplyAndKeepsFingerprintStableAcrossRuntimeIdChanges() + { + var parsed = VisibleMessageParser.Parse([new ChatMessageSource("引用:Alice\noriginal text\nreply text", "runtime-42")]); + var repeated = VisibleMessageParser.Parse([new ChatMessageSource("引用:Alice\noriginal text\nreply text", "runtime-99")]); + + var message = Assert.Single(parsed); + Assert.Equal("reply text", message.Text); + Assert.Equal("Alice", message.Quote?.Sender); + Assert.Equal("original text", message.Quote?.Text); + Assert.Equal(message.Fingerprint, Assert.Single(repeated).Fingerprint); + + var actualWechat = Assert.Single(VisibleMessageParser.Parse([ + new ChatMessageSource("reply text\n引用 Rogee 的消息 : 图片", "runtime-43") + ])); + Assert.Equal("reply text", actualWechat.Text); + Assert.Equal("Rogee", actualWechat.Quote?.Sender); + Assert.Equal("图片", actualWechat.Quote?.Text); + Assert.Equal(ChatMessageType.Quote, actualWechat.Type); + + var duplicates = VisibleMessageParser.Parse([new ChatMessageSource("same"), new ChatMessageSource("same")]); + Assert.NotEqual(duplicates[0].Fingerprint, duplicates[1].Fingerprint); + } + + [Fact] + public void DeduperIsBounded() + { + var deduper = new BoundedMessageDeduper(2); + Assert.True(deduper.TryAdd("a")); + Assert.False(deduper.TryAdd("a")); + Assert.True(deduper.TryAdd("b")); + Assert.True(deduper.TryAdd("c")); + Assert.True(deduper.TryAdd("a")); + } +} + +public sealed class MessageEventTests +{ + [Fact] + public void CheckpointSuppressesDuplicatesAndRecoversUnseenMessages() + { + var oldMessage = Assert.Single(VisibleMessageParser.Parse([new ChatMessageSource("old", "runtime-old")])); + var newMessage = Assert.Single(VisibleMessageParser.Parse([new ChatMessageSource("new", "runtime-new")])); + var initial = new MessageEventState(4); + Assert.NotNull(initial.TryCreateMessage("文件传输助手", oldMessage, DateTimeOffset.UnixEpoch, false)); + var checkpoint = initial.CreateCheckpoint("文件传输助手", DateTimeOffset.UnixEpoch); + Assert.Equal(2, checkpoint.Version); + + var resumed = new MessageEventState(4, checkpoint); + Assert.Null(resumed.TryCreateMessage("文件传输助手", oldMessage, DateTimeOffset.UnixEpoch, true)); + var recovered = resumed.TryCreateMessage("文件传输助手", newMessage, DateTimeOffset.UnixEpoch, true); + Assert.NotNull(recovered); + Assert.True(recovered!.Recovered); + } + + [Fact] + public async Task CallbackFailureDoesNotStopLaterCallbacks() + { + var called = false; + var messageEvent = new MessageEvent("id", MessageEventKind.Reconnected, "文件传输助手", null, DateTimeOffset.UnixEpoch, true); + var failures = await MessageCallbackDispatcher.DispatchAsync( + [ + (_, _) => throw new InvalidOperationException("boom"), + (_, _) => { called = true; return Task.CompletedTask; } + ], + messageEvent, + CancellationToken.None); + + Assert.True(called); + Assert.Single(failures); + Assert.Equal(0, failures[0].CallbackIndex); + } +} + +public sealed class SessionTests +{ + [Fact] + public void ParsesVisibleSessionsAndMarksCurrent() + { + var sessions = WechatSessionParser.Parse( + [("session_item_文件传输助手", "文件传输助手"), ("noise", "ignored"), ("session_item_文件传输助手", "duplicate")], + "文件传输助手"); + + var session = Assert.Single(sessions); + Assert.True(session.IsCurrent); + Assert.Equal("文件传输助手", session.Name); + } + + [Fact] + public void ParsesSearchResultsAndMarksOnlyExactLines() + { + var results = WechatSessionParser.ParseSearchResults( + [("search_item_function_文件传输助手", "功能\n文件传输助手"), ("search_item_friend_1", "联系人\n文件传输助手测试")], + "文件传输助手"); + + Assert.Equal(2, results.Count); + var exact = Assert.Single(results, result => result.IsExactMatch); + Assert.Equal("文件传输助手", exact.Name); + Assert.Equal("功能", exact.Category); + } +} + +public sealed class IdentityAndSanitizationTests +{ + [Fact] + public void AccountFingerprintUsesStableWindowsPathNormalization() + { + Assert.Equal( + AccountRootFingerprint.Create(@"c:/Users/Rogee/Documents/xwechat_files/account/db_storage/"), + AccountRootFingerprint.Create(@"C:\Users\Rogee\Documents\xwechat_files\account\db_storage")); + Assert.NotEqual(AccountRootFingerprint.Create(@"C:\one"), AccountRootFingerprint.Create(@"C:\two")); + } + + [Fact] + public void SnapshotSanitizerKeepsStructureAndRedactsContent() + { + Assert.Equal("session_list", UiSnapshotSanitizer.SanitizeAutomationId("session_list")); + Assert.NotEqual("发送", UiSnapshotSanitizer.SanitizeName("发送")); + Assert.StartsWith("[text:2:", UiSnapshotSanitizer.SanitizeName("发送")); + Assert.DoesNotContain("Alice", UiSnapshotSanitizer.SanitizeAutomationId("session_item_Alice")); + Assert.DoesNotContain("secret message", UiSnapshotSanitizer.SanitizeName("secret message")); + } + + [Fact] + public void ClassifiesLoggedOutAndChangedUiWithoutGuessingActiveAccount() + { + var controls = WechatLocators.RequiredAutomationIds.ToDictionary(id => id, _ => false); + Assert.Equal(WxAgentErrorCode.WechatNotLoggedIn, WechatLocators.ClassifyFoundWindow(controls)); + + controls[WechatLocators.MainView] = true; + Assert.Equal(WxAgentErrorCode.UiStructureChanged, WechatLocators.ClassifyFoundWindow(controls)); + + controls[WechatLocators.SessionList] = true; + Assert.Null(WechatLocators.ClassifyFoundWindow(controls)); + + controls[WechatLocators.ChatPage] = true; + Assert.Equal(WxAgentErrorCode.UiStructureChanged, WechatLocators.ClassifyFoundWindow(controls)); + + foreach (var id in controls.Keys.ToArray()) + { + controls[id] = true; + } + Assert.Null(WechatLocators.ClassifyFoundWindow(controls)); + } + + [Fact] + public void KeyStoreMergePreservesHistoricalAccountsAndDatabases() + { + var now = DateTimeOffset.UtcNow; + var oldDatabase = new DatabaseKeyEvidence("message/msg_0.db", "00", new string('1', 64), 1, now.AddDays(-1), KeyBindingConfidence.PageHmacVerified); + var newDatabase = new DatabaseKeyEvidence("contact/contact.db", "11", new string('2', 64), 2, now, KeyBindingConfidence.PageHmacVerified); + var existing = new[] + { + new AccountKeySet("account-a", @"C:\\a", "4.1.0", now.AddDays(-1), [oldDatabase]), + new AccountKeySet("account-history", @"C:\\history", "4.0.0", now.AddDays(-2), []) + }; + var incoming = new[] + { + new AccountKeySet("account-a", @"C:\\a", "4.1.8", now, [newDatabase]) + }; + + var merged = AccountKeySetMerge.Merge(existing, incoming); + + Assert.Equal(2, merged.Count); + var account = Assert.Single(merged, item => item.AccountRootFingerprint == "account-a"); + Assert.Equal("4.1.8", account.WechatVersion); + Assert.Equal(2, account.Databases.Count); + Assert.Contains(merged, item => item.AccountRootFingerprint == "account-history"); + } +} diff --git a/tests/WxAgent.Core.Tests/WxAgent.Core.Tests.csproj b/tests/WxAgent.Core.Tests/WxAgent.Core.Tests.csproj new file mode 100644 index 0000000..c8ddb60 --- /dev/null +++ b/tests/WxAgent.Core.Tests/WxAgent.Core.Tests.csproj @@ -0,0 +1,18 @@ + + + net8.0 + enable + enable + false + true + + + + + + + + + + +