diff --git a/docs/WebUI-MCP-使用说明.md b/docs/WebUI-MCP-使用说明.md index caaabf7..5a8d52f 100644 --- a/docs/WebUI-MCP-使用说明.md +++ b/docs/WebUI-MCP-使用说明.md @@ -31,9 +31,16 @@ MCP Streamable HTTP 地址为 `/mcp`。不要把 Token 放在 URL、MCP session 原子替换凭据文件并保留相同 `PrincipalId` 可保留幂等记录;服务在每个请求、任务执行和事件批次重新读取凭据。旧 Token、Cookie、SSE/MCP 授权立即失效,不存在重叠窗口。变更后删除旧浏览器会话并重新登录。 +## 多账号显式绑定 + +1. 在未锁定的交互式 Windows 会话中读取 `GET /api/v1/accounts` 和 `GET /api/v1/ui-targets`。 +2. 用户选择一个数据库 `accountId` 和一个当前窗口 `targetId`(PID+HWND),调用 `POST /api/v1/accounts/bind`;服务会重新读取 UI 微信号/昵称,并与已验证 `contact.db` 身份匹配,重复、过期或不匹配均拒绝。 +3. 绑定状态保存在服务数据目录的 `account-bindings.json`。微信进程/窗口消失、身份变化或重启后窗口句柄失效时,必须重新绑定;调用 `POST /api/v1/accounts/unbind` 可主动解除。 +4. 会话、可见消息、联系人等操作必须显式携带已绑定 `accountId`;未绑定返回 `AccountNotBound`/`AccountWindowUnavailable`,不会猜测窗口。 + ## 只读边界 -状态、账号、会话、摘要消息、联系人和任务查询通过 REST 或同一 MCP 工具访问。正文需 `content` 权限。当前写入、@所有人、管理和朋友圈能力保持 disabled;不会把入队、UI 点击或数据库指纹描述成已发送。 +状态、账号、窗口目标、会话、摘要消息、联系人和任务查询通过 REST 或同一 MCP 工具访问。正文需 `content` 权限。当前写入、@所有人、管理和朋友圈能力保持 disabled;不会把入队、UI 点击或数据库指纹描述成已发送。 ## 回滚 diff --git a/docs/WebUI-MCP-功能矩阵.md b/docs/WebUI-MCP-功能矩阵.md index 201c632..4cd1169 100644 --- a/docs/WebUI-MCP-功能矩阵.md +++ b/docs/WebUI-MCP-功能矩阵.md @@ -6,13 +6,14 @@ |---|---|---|---|---| | 服务/微信诊断 | `GET /api/v1/status`、`/api/v1/diagnostics` | `agent_status`、`agent_diagnose` | Ready/环境依赖 | 脱敏;服务在线不等于微信可用,不执行恢复或 UI 写操作 | | 能力清单 | `GET /api/v1/capabilities` | `agent_capabilities` | Ready | 各项 implemented/validated/enabled 分离 | -| 数据库账号发现 | `GET /api/v1/accounts` | `accounts_list` | 只读 | 仅返回指纹和脱敏 UI 信息,不返回密钥 | -| 可见会话 | `GET /api/v1/sessions`、`/search`、`/current`;POST `/open`、`/scroll` | `sessions_list`、`sessions_search`、`session_current` | 只读/导航 | 精确匹配拒绝猜测;open/scroll 需 manage 且当前仍待导航验收 | -| 可见消息 | `GET /api/v1/messages` | `messages_read` | 只读 | 默认摘要;`includeContent` 需要 content 权限 | +| 数据库账号发现 | `GET /api/v1/accounts` | `accounts_list` | 只读 | 仅返回指纹和绑定状态,不返回密钥 | +| 窗口枚举/显式绑定 | `GET /api/v1/ui-targets`、`POST /api/v1/accounts/bind`、`/unbind` | `ui_targets`、`account_bind`、`account_unbind` | Ready/需真机 | 用户选择 PID+HWND;绑定前读取 UI 身份并与已验证 contact.db 身份匹配,重复/不匹配拒绝 | +| 可见会话 | `GET /api/v1/sessions?accountId=...`、`/search`、`/current`;POST `/open`、`/scroll` | `sessions_list`、`sessions_search`、`session_current` | 只读/导航 | 所有 UI 调用必须携带已绑定 accountId;精确匹配拒绝猜测;open/scroll 需 manage 且当前仍待导航验收 | +| 可见消息 | `GET /api/v1/messages?accountId=...` | `messages_read` | 只读 | 必须使用已绑定 accountId;默认摘要;`includeContent` 需要 content 权限 | | 数据库消息/合并记录 | `GET /api/v1/db/messages`、`/api/v1/db/merged` | `db_messages`、`db_merged` | Implemented but disabled | 仅接受显式已验证账号 fingerprint;只读 SQLCipher,未提供通用 SQL | | 任务查询/取消 | `/api/v1/operations/{id}` | `operation_get`/`operation_cancel` | Ready | 只允许任务所有者;取消不撤销已发生副作用 | | 事件流 | `GET /api/v1/events` | 暂未注册 | Explicit opt-in | SSE 有界缓存、Last-Event-ID gap、Windows `ListenEventsAsync` 已接入;默认关闭,需受控真机验收后开启 | -| 文本/文件/卡片发送 | — | — | Disabled | 账号绑定、目标唯一性、真机后置验证未完成;绝不模拟成功 | +| 文本/文件/卡片发送 | — | — | Disabled | 绑定验证已具备,但写能力仍需目标唯一性、写后确认和真机验收;绝不模拟成功 | | 联系人/群管理、朋友圈 | — | — | Deferred/Disabled | 遵循 `docs/PENDING.md`,需单项授权和真机证据 | | 任意 SQL/UI 菜单/shell | — | — | Unsupported | 不提供 | diff --git a/docs/validation/WebUI-MCP-2026-09-07.md b/docs/validation/WebUI-MCP-2026-09-07.md index 50e4c98..7d034f0 100644 --- a/docs/validation/WebUI-MCP-2026-09-07.md +++ b/docs/validation/WebUI-MCP-2026-09-07.md @@ -13,14 +13,15 @@ | 能力 | 入口 | 结果 | |---|---|---| | 服务/微信诊断 | `GET /api/v1/status` | HTTP 200;`serviceOnline=true`、`wechatAvailable=true`、`sessionAvailable=true`、`windowFound=true`,错误为空 | -| 会话列表 | `GET /api/v1/sessions` | HTTP 200;返回可见会话,含 automationId | -| 会话当前/精确搜索 | `/api/v1/sessions/current`、`/search?exactOnly=true` | HTTP 200;当前会话和“文件传输助手”精确匹配均返回稳定 automationId | -| 账号发现 | `GET /api/v1/accounts` | HTTP 200;返回数据库指纹;UI 绑定保持 false | -| 联系人只读 | `GET /api/v1/contacts` | HTTP 200;返回稳定联系人 ID;未输出密钥 | +| 会话列表 | `GET /api/v1/sessions?accountId=...` | HTTP 200;返回可见会话,含 automationId;未携带 accountId 拒绝执行 | +| 会话当前/精确搜索 | `/api/v1/sessions/current?accountId=...`、`/search?accountId=...&exactOnly=true` | 需要显式绑定账号;当前锁屏环境不作为真机成功证据 | +| 账号发现/窗口目标 | `GET /api/v1/accounts`、`GET /api/v1/ui-targets` | HTTP 200;返回数据库指纹、绑定状态和当前窗口目标 | +| 显式账号绑定 | `POST /api/v1/accounts/bind` | 已实现身份匹配、PID+HWND 持久化和冲突拒绝;锁屏环境未执行成功绑定 | +| 联系人只读 | `GET /api/v1/contacts?accountId=...` | 需要显式账号;返回稳定联系人 ID;未输出密钥 | | 群成员只读 | `GET /api/v1/groups/{accountId}/{group}/members` | HTTP 200;显式账号范围与测试群 `消息测试专用群组`,返回稳定成员 ID | | SSE 单帧 | `GET /api/v1/events` | HTTP 200;真实 `id/event/data` 帧,正文不进入事件;默认仍需显式 opt-in | -| 可见消息摘要 | `GET /api/v1/messages` | HTTP 200;返回 fingerprint/type/summary,正文为 null | +| 可见消息摘要 | `GET /api/v1/messages?accountId=...` | 需要显式绑定账号;返回 fingerprint/type/summary,正文为 null | | MCP | `/mcp` initialize、`tools/list` | HTTP 200;同一 Bearer Token;工具 schema 可加载 | | Web 静态入口 | `/` | HTTP 200;静态资源随发布包部署 | @@ -36,14 +37,20 @@ ## 自动化证据 -- `dotnet test tests/WxAgent.Core.Tests -c Release`:150 passed。 -- `dotnet test tests/WxAgent.Service.Tests -c Release`:11 passed。 +- `dotnet test tests/WxAgent.Core.Tests -c Release`:153 passed。 +- `dotnet test tests/WxAgent.Service.Tests -c Release`:12 passed。 - `dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true`:成功,0 warning/0 error。 - self-contained `win-x64` publish:成功,包含 `wwwroot` 与 MCP 依赖。 +## 本轮多账号绑定验证 + +- Windows API 实例在锁屏/非交互 SSH 会话下返回 1 个脱敏 PID+HWND 目标和 1 个 `Unbound` 数据库账号;身份字段保持空值,未猜测绑定。 +- 缺少 `accountId` 的 sessions/contacts/messages 请求分别返回 HTTP 400 `AccountIdRequired`。 +- `doctor`/`inspect-ui`/`smoke` 在当前会话返回 `SessionLocked`/`WindowNotFound`;未把锁屏结果当作绑定成功证据。 + ## 未完成/不宣称 - 未执行发送、联系人/群管理、朋友圈、语音等写操作;`send-text`、`group-at-all` 和 deferred 项保持禁用。 -- 未完成 60 分钟/20 条消息监听、断线补齐和多客户端慢消费者真机验收;SSE 与服务端监听源已实现,默认配置 `EnableListenerEvents=false`。自动化高频探针已中止,不作为验收证据;后续真机消息验证上限 3 条。 +- 未完成窗口绑定成功和 60 分钟/20 条消息监听、断线补齐和多客户端慢消费者真机验收;SSE 与服务端监听源已实现,默认配置 `EnableListenerEvents=false`。自动化高频探针已中止,不作为验收证据;后续真机消息验证上限 3 条。 - 未完成外部主机直接访问验收(防火墙未开放);不将 HTTP 鉴权描述为网络加密。 - 远程验证任务已停止并清理;不再自动发送测试消息,后续真机消息验证上限为 3 条。 diff --git a/src/WxAgent.Host/Program.cs b/src/WxAgent.Host/Program.cs index b85d88e..a93f5e4 100644 --- a/src/WxAgent.Host/Program.cs +++ b/src/WxAgent.Host/Program.cs @@ -30,7 +30,7 @@ try var serviceOptions = JsonSerializer.Deserialize( await File.ReadAllTextAsync(GetRequiredOption(args, "--config"), shutdown.Token), ServiceHost.Json) ?? throw new ArgumentException("A service configuration is required."); - await using var app = ServiceHost.Build(serviceOptions, new WindowsAgentBackend(serviceOptions)); + await using var app = ServiceHost.Build(serviceOptions, new WindowsAgentBackend(serviceOptions, new AccountBindingStore(serviceOptions))); await app.StartAsync(shutdown.Token); try { await Task.Delay(Timeout.InfiniteTimeSpan, shutdown.Token); } catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { } diff --git a/src/WxAgent.Host/WindowsAgentBackend.cs b/src/WxAgent.Host/WindowsAgentBackend.cs index 6386c57..8368b16 100644 --- a/src/WxAgent.Host/WindowsAgentBackend.cs +++ b/src/WxAgent.Host/WindowsAgentBackend.cs @@ -4,13 +4,16 @@ using WxAgent.Windows; namespace WxAgent.Host; -internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBackend, IAgentEventSource +internal sealed class WindowsAgentBackend(ServiceOptions options, AccountBindingStore bindings) : IAgentBackend, IAgentEventSource { + private readonly SemaphoreSlim bindingGate = new(1, 1); + public IReadOnlyList Capabilities { get; } = [ new("agent-status", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), new("agent-diagnose", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), new("accounts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), + new("account-binding", true, false, true, true, true, "manage", false, 30, "Requires an explicit target and verified database/UI identity.", ["docs/WebUI-MCP-开发计划.md"]), new("sessions-list", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), new("sessions-search", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), new("session-current", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), @@ -33,24 +36,180 @@ internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBacken public async IAsyncEnumerable ListenAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { - var checkpoint = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WxAgent", "listener-checkpoint.json"); + var active = bindings.ReadAll(); + if (active.Count != 1) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + yield break; + } + var binding = active[0]; + using var scope = WechatChatClient.UseWindowTarget(binding.ProcessId, binding.WindowHandle); + var current = await WechatChatClient.GetMyInfoAsync(cancellationToken); + if (!BindingMatches(binding, current)) + throw new ServiceException("AccountBindingStale", 409, "The listener binding is stale; bind the account again."); + var accountTag = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(binding.AccountId)))[..16].ToLowerInvariant(); + var checkpoint = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WxAgent", $"listener-{accountTag}.json"); while (!cancellationToken.IsCancellationRequested) { await foreach (var item in WechatChatClient.ListenEventsAsync(TimeSpan.FromMinutes(5), checkpoint, cancellationToken, session: options.ListenerSession)) { - yield return new AgentEvent(item.EventId, "ui-current", item.Session, + yield return new AgentEvent(item.EventId, binding.AccountId, item.Session, item.Kind.ToString(), item.Message is null ? "listener state" : item.Message.Type.ToString(), item.ObservedAt); } } } - public Task> AccountsAsync(CancellationToken cancellationToken) + private static bool BindingMatches(AccountBinding binding, WechatAccountSnapshot current) => + !string.IsNullOrWhiteSpace(binding.WechatId) + ? string.Equals(binding.WechatId, current.WechatId, StringComparison.OrdinalIgnoreCase) + : string.Equals(binding.Nickname, current.DisplayName, StringComparison.Ordinal); + + public async Task> AccountsAsync(CancellationToken cancellationToken) { + var targets = await ReadUiTargetsAsync(cancellationToken, false); var roots = WechatDatabaseDiscovery.FindAccountRoots(cancellationToken: cancellationToken); - // Database fingerprints are deliberately not merged with the current UI identity. - return Task.FromResult>(roots.Select(root => new AccountInfo(root.Fingerprint, null, null, null, - root.Fingerprint, false)).ToArray()); + var current = bindings.ReadAll(); + var live = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var binding in current) + { + if (!targets.Any(target => target.IsBound && target.ProcessId == binding.ProcessId && target.WindowHandle == binding.WindowHandle)) continue; + try + { + using var scope = WechatChatClient.UseWindowTarget(binding.ProcessId, binding.WindowHandle); + if (BindingMatches(binding, await WechatChatClient.GetMyInfoAsync(cancellationToken))) live.Add(binding.AccountId); + } + catch (WxAgentException) { } + } + return roots.Select(root => + { + var binding = bindings.Get(root.Fingerprint); + var isLive = binding is not null && live.Contains(binding.AccountId); + return new AccountInfo(root.Fingerprint, null, null, null, root.Fingerprint, isLive, binding, + binding is null ? "Unbound" : isLive ? "Bound" : "Stale"); + }).ToArray(); + } + + public Task> UiTargetsAsync(CancellationToken cancellationToken) => + ReadUiTargetsAsync(cancellationToken, true); + + private async Task> ReadUiTargetsAsync(CancellationToken cancellationToken, bool readIdentity) + { + var bound = bindings.ReadAll().Select(binding => (binding.ProcessId, binding.WindowHandle)).ToHashSet(new BindingTargetComparer()); + var targets = new List(); + foreach (var window in WechatChatClient.InspectMainWindows().Where(window => window.Visible && !window.Minimized)) + { + cancellationToken.ThrowIfCancellationRequested(); + string? wechatId = null; + string? nickname = null; + if (readIdentity) + { + try + { + using var scope = WechatChatClient.UseWindowTarget(window.ProcessId, window.Handle); + var identity = await WechatChatClient.GetMyInfoAsync(cancellationToken); + wechatId = identity.WechatId; + nickname = identity.DisplayName; + } + catch (WxAgentException) { } + } + targets.Add(new UiTargetInfo(TargetId(window.ProcessId, window.Handle), window.ProcessId, window.Handle, + window.Title, wechatId, nickname, bound.Contains((window.ProcessId, window.Handle)))); + } + return targets; + } + + public async Task BindAccountAsync(string accountId, string targetId, CancellationToken cancellationToken) + { + if (!TryParseTargetId(targetId, out var processId, out var windowHandle)) + throw new ServiceException("InvalidTarget", 400, "targetId is not a current WeChat window target."); + var target = (await ReadUiTargetsAsync(cancellationToken, false)).SingleOrDefault(x => x.TargetId == targetId); + if (target is null) throw new ServiceException("TargetUnavailable", 409, "The selected WeChat window is not available."); + + var account = await WechatContactDbReader.LoadAccountAsync(accountId, cancellationToken: cancellationToken); + WechatAccountSnapshot ui; + using (WechatChatClient.UseWindowTarget(processId, windowHandle)) + ui = await WechatChatClient.GetMyInfoAsync(cancellationToken); + var databaseIdentity = await TryReadDatabaseIdentityAsync(account, ui, cancellationToken) + ?? throw new ServiceException("AccountIdentityUnavailable", 409, "The selected database account identity could not be read safely."); + var match = AccountBindingMatcher.Match(new UiAccountIdentity(ui.WechatId, ui.DisplayName), [databaseIdentity]); + if (!match.IsMatched || !string.Equals(match.AccountId, account.AccountRootFingerprint, StringComparison.OrdinalIgnoreCase)) + throw new ServiceException("AccountBindingMismatch", 409, "The selected WeChat window does not match the selected database account."); + + await bindingGate.WaitAsync(cancellationToken); + try + { + var current = bindings.ReadAll(); + var accountMatches = current.Where(x => string.Equals(x.AccountId, account.AccountRootFingerprint, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (accountMatches.Length > 1) + throw new ServiceException("BindingAmbiguous", 409, "The persisted account binding is ambiguous; remove account-bindings.json and bind again."); + var accountBinding = accountMatches.SingleOrDefault(); + if (accountBinding is not null && (accountBinding.ProcessId != processId || accountBinding.WindowHandle != windowHandle)) + throw new ServiceException("AccountAlreadyBound", 409, "The account is already bound to another WeChat window; unbind it first."); + var targetMatches = current.Where(x => x.ProcessId == processId && x.WindowHandle == windowHandle).ToArray(); + if (targetMatches.Length > 1) + throw new ServiceException("BindingAmbiguous", 409, "The persisted window binding is ambiguous; remove account-bindings.json and bind again."); + var targetBinding = targetMatches.SingleOrDefault(); + if (targetBinding is not null && !string.Equals(targetBinding.AccountId, account.AccountRootFingerprint, StringComparison.OrdinalIgnoreCase)) + throw new ServiceException("TargetAlreadyBound", 409, "The selected WeChat window is already bound to another account."); + + var binding = new AccountBinding(account.AccountRootFingerprint, processId, windowHandle, ui.WechatId, + ui.DisplayName ?? string.Empty, DateTimeOffset.UtcNow); + bindings.Replace(current.Where(x => !string.Equals(x.AccountId, binding.AccountId, StringComparison.OrdinalIgnoreCase)).Append(binding)); + return binding; + } + finally { bindingGate.Release(); } + } + + public async Task UnbindAccountAsync(string accountId, CancellationToken cancellationToken) + { + await bindingGate.WaitAsync(cancellationToken); + try { bindings.Remove(accountId); } + finally { bindingGate.Release(); } + } + + private static string TargetId(int processId, long windowHandle) => + $"{processId.ToString(System.Globalization.CultureInfo.InvariantCulture)}:{windowHandle.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; + + private static bool TryParseTargetId(string targetId, out int processId, out long windowHandle) + { + processId = 0; + windowHandle = 0; + var parts = targetId.Split(':', 2); + return parts.Length == 2 && int.TryParse(parts[0], System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out processId) && + long.TryParse(parts[1], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out windowHandle) && + processId > 0 && windowHandle > 0; + } + + private static async Task TryReadDatabaseIdentityAsync(AccountKeySet account, WechatAccountSnapshot ui, CancellationToken cancellationToken) + { + try + { + if (!string.IsNullOrWhiteSpace(ui.WechatId)) + { + var page = await WechatContactDbReader.ReadPageAsync(account, 10000, 0, ui.WechatId, false, cancellationToken); + var exact = page.Contacts.Where(x => string.Equals(x.Username, ui.WechatId, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (!page.HasMore && exact.Length == 1) + return new DatabaseAccountIdentity(account.AccountRootFingerprint, exact[0].Username, exact[0].DisplayName ?? exact[0].Remark); + } + if (!string.IsNullOrWhiteSpace(ui.DisplayName)) + { + var page = await WechatContactDbReader.ReadPageAsync(account, 10000, 0, ui.DisplayName, false, cancellationToken); + var exact = page.Contacts.Where(x => string.Equals(x.DisplayName, ui.DisplayName, StringComparison.Ordinal) || + string.Equals(x.Remark, ui.DisplayName, StringComparison.Ordinal)).ToArray(); + if (!page.HasMore && exact.Length == 1) + return new DatabaseAccountIdentity(account.AccountRootFingerprint, exact[0].Username, exact[0].DisplayName ?? exact[0].Remark); + } + return null; + } + catch (WxAgentException) { return null; } + } + + private sealed class BindingTargetComparer : IEqualityComparer<(int ProcessId, long WindowHandle)> + { + public bool Equals((int ProcessId, long WindowHandle) x, (int ProcessId, long WindowHandle) y) => x == y; + public int GetHashCode((int ProcessId, long WindowHandle) value) => HashCode.Combine(value.ProcessId, value.WindowHandle); } public async Task> SessionsAsync(CancellationToken cancellationToken) @@ -59,18 +218,27 @@ internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBacken return sessions.Select(s => new SessionInfo(s.Name, s.AutomationId, s.IsCurrent)).ToArray(); } + public Task> SessionsAsync(string? accountId, CancellationToken cancellationToken) => + ForAccountAsync(accountId, cancellationToken, () => SessionsAsync(cancellationToken)); + public async Task> SearchSessionsAsync(string query, bool exactOnly, CancellationToken cancellationToken) { var results = await WechatChatClient.SearchSessionsAsync(query, exactOnly, cancellationToken); return results.Select(s => new SessionSearchInfo(s.Name, s.AutomationId, s.IsExactMatch)).ToArray(); } + public Task> SearchSessionsAsync(string? accountId, string query, bool exactOnly, CancellationToken cancellationToken) => + ForAccountAsync(accountId, cancellationToken, () => SearchSessionsAsync(query, exactOnly, cancellationToken)); + public async Task CurrentSessionAsync(CancellationToken cancellationToken) { var current = await WechatChatClient.GetCurrentSessionAsync(cancellationToken); return current is null ? null : new SessionInfo(current.Name, current.AutomationId, true); } + public Task CurrentSessionAsync(string? accountId, CancellationToken cancellationToken) => + ForAccountAsync(accountId, cancellationToken, () => CurrentSessionAsync(cancellationToken)); + public async Task OpenSessionAsync(string automationId, CancellationToken cancellationToken) { var matches = (await WechatChatClient.ListVisibleSessionsAsync(cancellationToken)).Where(s => s.AutomationId == automationId).ToArray(); @@ -79,6 +247,9 @@ internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBacken return new SessionInfo(matches[0].Name, matches[0].AutomationId, true); } + public Task OpenSessionAsync(string? accountId, string automationId, CancellationToken cancellationToken) => + ForAccountAsync(accountId, cancellationToken, () => OpenSessionAsync(automationId, cancellationToken)); + public async Task ScrollSessionsAsync(string direction, int pages, CancellationToken cancellationToken) { var result = await WechatChatClient.ScrollSessionsAsync(direction == "up" ? WechatScrollDirection.Up : WechatScrollDirection.Down, pages, cancellationToken); @@ -86,6 +257,9 @@ internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBacken result.Sessions.Select(s => new SessionInfo(s.Name, s.AutomationId, s.IsCurrent)).ToArray()); } + public Task ScrollSessionsAsync(string? accountId, string direction, int pages, CancellationToken cancellationToken) => + ForAccountAsync(accountId, cancellationToken, () => ScrollSessionsAsync(direction, pages, cancellationToken)); + public async Task> MessagesAsync(string? session, bool includeContent, CancellationToken cancellationToken) { if (!string.IsNullOrWhiteSpace(session)) @@ -99,8 +273,37 @@ internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBacken m.Text.Length > 160 ? m.Text[..160] : m.Text, includeContent ? m.Text : null)).ToArray(); } + public Task> MessagesAsync(string? accountId, string? session, bool includeContent, CancellationToken cancellationToken) => + ForAccountAsync(accountId, cancellationToken, () => MessagesAsync(session, includeContent, cancellationToken)); + + private async Task ForAccountAsync(string? accountId, CancellationToken cancellationToken, Func> action) + { + if (string.IsNullOrWhiteSpace(accountId)) + throw new ServiceException("AccountIdRequired", 400, "accountId is required for every WeChat UI operation."); + var matches = bindings.ReadAll().Where(x => string.Equals(x.AccountId, accountId, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (matches.Length > 1) + throw new ServiceException("BindingAmbiguous", 409, "The persisted account binding is ambiguous; bind state must be repaired before use."); + var binding = matches.SingleOrDefault(); + if (binding is null) + throw new ServiceException("AccountNotBound", 409, "The account is not explicitly bound to a WeChat window."); + var target = (await ReadUiTargetsAsync(cancellationToken, false)).SingleOrDefault(x => + x.ProcessId == binding.ProcessId && x.WindowHandle == binding.WindowHandle); + if (target is null) + throw new ServiceException("AccountWindowUnavailable", 409, "The bound WeChat window is no longer available."); + using var scope = WechatChatClient.UseWindowTarget(binding.ProcessId, binding.WindowHandle); + var current = await WechatChatClient.GetMyInfoAsync(cancellationToken); + var identityMatches = !string.IsNullOrWhiteSpace(binding.WechatId) + ? string.Equals(binding.WechatId, current.WechatId, StringComparison.OrdinalIgnoreCase) + : string.Equals(binding.Nickname, current.DisplayName, StringComparison.Ordinal); + if (!identityMatches) + throw new ServiceException("AccountBindingStale", 409, "The bound WeChat window identity changed; bind the account again."); + return await action(); + } + public async Task> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken cancellationToken) { + if (string.IsNullOrWhiteSpace(accountId)) + throw new ServiceException("AccountIdRequired", 400, "accountId is required for database contact operations."); var page = await WechatChatClient.GetContactsPageAsync(limit, offset, contains, groupsOnly, accountId, cancellationToken: cancellationToken); var items = page.Contacts.Select(c => new ContactInfo(c.Username, c.DisplayName, c.Remark, null)).ToArray(); return new Page(items, limit, offset, page.HasMore, page.NextOffset); @@ -152,7 +355,7 @@ internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBacken report.WindowFound, errors = report.Errors.Select(e => e.ToString()), wechatVersions = report.Processes.Select(p => p.Version).Where(v => v is not null).Distinct(), - activeAccountBound = false, + activeAccountBound = bindings.ReadAll().Count != 0, defaultReadOnly = true }); } diff --git a/src/WxAgent.Service/AccountBindingStore.cs b/src/WxAgent.Service/AccountBindingStore.cs new file mode 100644 index 0000000..bbb2e14 --- /dev/null +++ b/src/WxAgent.Service/AccountBindingStore.cs @@ -0,0 +1,42 @@ +using System.Text.Json; + +namespace WxAgent.Service; + +public sealed record AccountBinding(string AccountId, int ProcessId, long WindowHandle, string? WechatId, string Nickname, DateTimeOffset BoundAt); +public sealed record UiTargetInfo(string TargetId, int ProcessId, long WindowHandle, string? Title, string? WechatId, string? Nickname, bool IsBound); + +public sealed class AccountBindingStore(ServiceOptions options) +{ + private readonly string path = Path.Combine(options.DataDirectory, "account-bindings.json"); + private readonly object gate = new(); + + public IReadOnlyList ReadAll() + { + lock (gate) + { + if (!File.Exists(path)) return []; + try { return JsonSerializer.Deserialize(File.ReadAllText(path), ServiceHost.Json) ?? []; } + catch (JsonException) { return []; } + } + } + + public AccountBinding? Get(string accountId) + { + var matches = ReadAll().Where(x => string.Equals(x.AccountId, accountId, StringComparison.OrdinalIgnoreCase)).ToArray(); + return matches.Length == 1 ? matches[0] : null; + } + + public void Replace(IEnumerable bindings) + { + var items = bindings.GroupBy(x => x.AccountId, StringComparer.OrdinalIgnoreCase).Select(x => x.Last()).ToArray(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + lock (gate) + { + var temp = path + ".tmp"; + File.WriteAllText(temp, JsonSerializer.Serialize(items, ServiceHost.Json)); + File.Move(temp, path, true); + } + } + + public void Remove(string accountId) => Replace(ReadAll().Where(x => !string.Equals(x.AccountId, accountId, StringComparison.OrdinalIgnoreCase))); +} diff --git a/src/WxAgent.Service/AgentService.cs b/src/WxAgent.Service/AgentService.cs index 19bbcce..854ed28 100644 --- a/src/WxAgent.Service/AgentService.cs +++ b/src/WxAgent.Service/AgentService.cs @@ -13,19 +13,28 @@ public interface IAgentBackend Task StatusAsync(CancellationToken cancellationToken); Task DiagnoseAsync(CancellationToken cancellationToken) => StatusAsync(cancellationToken); Task> AccountsAsync(CancellationToken cancellationToken) => Task.FromResult>([]); + Task> UiTargetsAsync(CancellationToken cancellationToken) => Task.FromResult>([]); + Task BindAccountAsync(string accountId, string targetId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Account binding is not available."); + Task UnbindAccountAsync(string accountId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Account binding is not available."); Task> SessionsAsync(CancellationToken cancellationToken) => Task.FromResult>([]); + Task> SessionsAsync(string? accountId, CancellationToken cancellationToken) => SessionsAsync(cancellationToken); Task> SearchSessionsAsync(string query, bool exactOnly, CancellationToken cancellationToken) => Task.FromResult>([]); + Task> SearchSessionsAsync(string? accountId, string query, bool exactOnly, CancellationToken cancellationToken) => SearchSessionsAsync(query, exactOnly, cancellationToken); Task CurrentSessionAsync(CancellationToken cancellationToken) => Task.FromResult(null); + Task CurrentSessionAsync(string? accountId, CancellationToken cancellationToken) => CurrentSessionAsync(cancellationToken); Task OpenSessionAsync(string automationId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Session opening is not available."); + Task OpenSessionAsync(string? accountId, string automationId, CancellationToken cancellationToken) => OpenSessionAsync(automationId, cancellationToken); Task ScrollSessionsAsync(string direction, int pages, CancellationToken cancellationToken) => Task.FromResult(new SessionViewportInfo(0, false, [])); + Task ScrollSessionsAsync(string? accountId, string direction, int pages, CancellationToken cancellationToken) => ScrollSessionsAsync(direction, pages, cancellationToken); Task> MessagesAsync(string? session, bool includeContent, CancellationToken cancellationToken) => Task.FromResult>([]); + Task> MessagesAsync(string? accountId, string? session, bool includeContent, CancellationToken cancellationToken) => MessagesAsync(session, includeContent, cancellationToken); Task> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken cancellationToken) => Task.FromResult(new Page([], limit, offset, false, null)); Task> GroupMembersAsync(string accountId, string group, int limit, int offset, CancellationToken cancellationToken) => Task.FromResult(new Page([], limit, offset, false, null)); Task> DatabaseMessagesAsync(string accountId, string chatId, int limit, long? localId, CancellationToken cancellationToken) => Task.FromResult>([]); Task DatabaseMergedAsync(string accountId, string chatId, long localId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Merged database messages are not available."); } -public sealed class AgentService(IAgentBackend backend, ServiceSecurity security, IHttpContextAccessor contexts, OperationQueue operations, ArtifactStore artifacts) +public sealed class AgentService(IAgentBackend backend, ServiceSecurity security, IHttpContextAccessor contexts, OperationQueue operations, ArtifactStore artifacts, AccountBindingStore bindings) { public ServiceIdentity Identity => contexts.HttpContext?.Items[typeof(ServiceIdentity)] as ServiceIdentity ?? throw new ServiceException("Unauthorized", 401, "Authentication required."); @@ -52,32 +61,75 @@ public sealed class AgentService(IAgentBackend backend, ServiceSecurity security } public async Task> AccountsAsync(int limit, int offset, CancellationToken ct) - { RequireCapability("accounts-list"); ReadOnlyRequest.Page(limit, offset); return (await backend.AccountsAsync(ct)).ToPage(limit, offset); } + { + RequireCapability("accounts-list"); ReadOnlyRequest.Page(limit, offset); + var accounts = (await backend.AccountsAsync(ct)).Select(account => + { + var binding = bindings.Get(account.AccountId); + return account with { Binding = binding, IsUiBindingKnown = account.BindingStatus == "Bound" }; + }).ToArray(); + return accounts.ToPage(limit, offset); + } - public async Task> SessionsAsync(int limit, int offset, CancellationToken ct) - { RequireCapability("sessions-list"); ReadOnlyRequest.Page(limit, offset); return (await backend.SessionsAsync(ct)).ToPage(limit, offset); } + public IReadOnlyList Bindings() + { security.RequireCurrent(Identity, "read"); return bindings.ReadAll(); } - public async Task> SearchSessionsAsync(string query, bool exactOnly, int limit, int offset, CancellationToken ct) - { RequireCapability("sessions-search"); ReadOnlyRequest.Page(limit, offset); if (string.IsNullOrWhiteSpace(query) || query.Length > 200) throw new ServiceException("InvalidRequest", 400, "query must be 1..200 characters."); return (await backend.SearchSessionsAsync(query, exactOnly, ct)).ToPage(limit, offset); } + public async Task> UiTargetsAsync(CancellationToken ct) + { security.RequireCurrent(Identity, "read"); return await backend.UiTargetsAsync(ct); } - public async Task CurrentSessionAsync(CancellationToken ct) - { RequireCapability("session-current"); return await backend.CurrentSessionAsync(ct) ?? throw new ServiceException("NotFound", 404, "No current session."); } + public async Task BindAccountAsync(string accountId, string targetId, CancellationToken ct) + { + RequireCapability("account-binding"); + if (string.IsNullOrWhiteSpace(accountId) || accountId.Length > 200 || string.IsNullOrWhiteSpace(targetId) || targetId.Length > 200) + throw new ServiceException("InvalidRequest", 400, "accountId and targetId are required and bounded."); + return await backend.BindAccountAsync(accountId, targetId, ct); + } - public async Task OpenSessionAsync(string automationId, CancellationToken ct) - { RequireCapability("session-open"); if (string.IsNullOrWhiteSpace(automationId) || automationId.Length > 512) throw new ServiceException("InvalidRequest", 400, "automationId is required and bounded."); return await backend.OpenSessionAsync(automationId, ct); } + public async Task UnbindAccountAsync(string accountId, CancellationToken ct) + { + RequireCapability("account-binding"); + if (string.IsNullOrWhiteSpace(accountId) || accountId.Length > 200) + throw new ServiceException("InvalidRequest", 400, "accountId is required and bounded."); + await backend.UnbindAccountAsync(accountId, ct); + } - public async Task ScrollSessionsAsync(string direction, int pages, CancellationToken ct) - { RequireCapability("sessions-scroll"); if (pages is < 1 or > 10 || direction is not ("up" or "down")) throw new ServiceException("InvalidRequest", 400, "direction must be up/down and pages must be 1..10."); return await backend.ScrollSessionsAsync(direction, pages, ct); } + public async Task> SessionsAsync(string? accountId, int limit, int offset, CancellationToken ct) + { RequireCapability("sessions-list"); ReadOnlyRequest.Page(limit, offset); return (await backend.SessionsAsync(accountId, ct)).ToPage(limit, offset); } - public async Task> MessagesAsync(string? session, int limit, int offset, bool includeContent, CancellationToken ct) + public Task> SessionsAsync(int limit, int offset, CancellationToken ct) => SessionsAsync(null, limit, offset, ct); + + public async Task> SearchSessionsAsync(string? accountId, string query, bool exactOnly, int limit, int offset, CancellationToken ct) + { RequireCapability("sessions-search"); ReadOnlyRequest.Page(limit, offset); if (string.IsNullOrWhiteSpace(query) || query.Length > 200) throw new ServiceException("InvalidRequest", 400, "query must be 1..200 characters."); return (await backend.SearchSessionsAsync(accountId, query, exactOnly, ct)).ToPage(limit, offset); } + + public Task> SearchSessionsAsync(string query, bool exactOnly, int limit, int offset, CancellationToken ct) => SearchSessionsAsync(null, query, exactOnly, limit, offset, ct); + + public async Task CurrentSessionAsync(string? accountId, CancellationToken ct) + { RequireCapability("session-current"); return await backend.CurrentSessionAsync(accountId, ct) ?? throw new ServiceException("NotFound", 404, "No current session."); } + + public Task CurrentSessionAsync(CancellationToken ct) => CurrentSessionAsync(null, ct); + + public async Task OpenSessionAsync(string? accountId, string automationId, CancellationToken ct) + { RequireCapability("session-open"); if (string.IsNullOrWhiteSpace(automationId) || automationId.Length > 512) throw new ServiceException("InvalidRequest", 400, "automationId is required and bounded."); return await backend.OpenSessionAsync(accountId, automationId, ct); } + + public Task OpenSessionAsync(string automationId, CancellationToken ct) => OpenSessionAsync(null, automationId, ct); + + public async Task ScrollSessionsAsync(string? accountId, string direction, int pages, CancellationToken ct) + { RequireCapability("sessions-scroll"); if (pages is < 1 or > 10 || direction is not ("up" or "down")) throw new ServiceException("InvalidRequest", 400, "direction must be up/down and pages must be 1..10."); return await backend.ScrollSessionsAsync(accountId, direction, pages, ct); } + + public Task ScrollSessionsAsync(string direction, int pages, CancellationToken ct) => ScrollSessionsAsync(null, direction, pages, ct); + + public async Task> MessagesAsync(string? accountId, string? session, int limit, int offset, bool includeContent, CancellationToken ct) { RequireCapability("messages-read"); var identity = security.RequireCurrent(Identity, includeContent ? "content" : "read"); ReadOnlyRequest.Page(limit, offset); - var values = await backend.MessagesAsync(session, includeContent && identity.Allows("content"), ct); + var values = await backend.MessagesAsync(accountId, session, includeContent && identity.Allows("content"), ct); return values.ToPage(limit, offset); } + public Task> MessagesAsync(string? session, int limit, int offset, bool includeContent, CancellationToken ct) => + MessagesAsync(null, session, limit, offset, includeContent, ct); + public async Task> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken ct) { RequireCapability("contacts-list"); diff --git a/src/WxAgent.Service/AgentTools.cs b/src/WxAgent.Service/AgentTools.cs index f6b8d56..93d554f 100644 --- a/src/WxAgent.Service/AgentTools.cs +++ b/src/WxAgent.Service/AgentTools.cs @@ -18,23 +18,32 @@ public sealed class AgentTools(AgentService service) [McpServerTool(Name = "agent_diagnose", ReadOnly = true), Description("Run redacted diagnostics without changing the WeChat UI.")] public Task Diagnose(CancellationToken cancellationToken = default) => Result(async () => await service.DiagnoseAsync(cancellationToken)); - [McpServerTool(Name = "accounts_list", ReadOnly = true), Description("List explicit database accounts; a fingerprint does not prove current UI identity.")] + [McpServerTool(Name = "accounts_list", ReadOnly = true), Description("List database accounts and explicit binding state; a fingerprint does not prove current UI identity.")] public Task Accounts(int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.AccountsAsync(limit, offset, cancellationToken)); - [McpServerTool(Name = "sessions_list", ReadOnly = true), Description("List the visible WeChat sessions.")] - public Task Sessions(int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.SessionsAsync(limit, offset, cancellationToken)); + [McpServerTool(Name = "ui_targets", ReadOnly = true), Description("List current visible WeChat window targets for explicit account binding.")] + public Task UiTargets(CancellationToken cancellationToken = default) => Result(async () => await service.UiTargetsAsync(cancellationToken)); - [McpServerTool(Name = "sessions_search", ReadOnly = true), Description("Search visible sessions; exact targeting is explicit and ambiguous matches are not guessed.")] - public Task SearchSessions(string query, bool exactOnly = false, int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.SearchSessionsAsync(query, exactOnly, limit, offset, cancellationToken)); + [McpServerTool(Name = "account_bind"), Description("Explicitly bind one verified database account to one current WeChat window after identity verification.")] + public Task BindAccount(string accountId, string targetId, CancellationToken cancellationToken = default) => Result(async () => await service.BindAccountAsync(accountId, targetId, cancellationToken)); - [McpServerTool(Name = "session_current", ReadOnly = true), Description("Read the current visible WeChat session.")] - public Task CurrentSession(CancellationToken cancellationToken = default) => Result(async () => await service.CurrentSessionAsync(cancellationToken)); + [McpServerTool(Name = "account_unbind"), Description("Remove one explicit account-to-window binding; it does not affect WeChat or database data.")] + public Task UnbindAccount(string accountId, CancellationToken cancellationToken = default) => Result(async () => { await service.UnbindAccountAsync(accountId, cancellationToken); return new { accountId, unbound = true }; }); - [McpServerTool(Name = "messages_read", ReadOnly = true), Description("Read bounded visible messages; content requires the content permission.")] - public Task Messages(string? session = null, int limit = 50, int offset = 0, bool includeContent = false, CancellationToken cancellationToken = default) => Result(async () => await service.MessagesAsync(session, limit, offset, includeContent, cancellationToken)); + [McpServerTool(Name = "sessions_list", ReadOnly = true), Description("List visible sessions for an explicitly bound account.")] + public Task Sessions(string accountId, int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.SessionsAsync(accountId, limit, offset, cancellationToken)); - [McpServerTool(Name = "contacts_list", ReadOnly = true), Description("Read stable-ID contacts from the selected read-only database account.")] - public Task Contacts(string? accountId = null, string? contains = null, bool? groupsOnly = null, int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.ContactsAsync(accountId, contains, groupsOnly, limit, offset, cancellationToken)); + [McpServerTool(Name = "sessions_search", ReadOnly = true), Description("Search sessions for an explicitly bound account; ambiguous matches are not guessed.")] + public Task SearchSessions(string query, string accountId, bool exactOnly = false, int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.SearchSessionsAsync(accountId, query, exactOnly, limit, offset, cancellationToken)); + + [McpServerTool(Name = "session_current", ReadOnly = true), Description("Read the current session for an explicitly bound account.")] + public Task CurrentSession(string accountId, CancellationToken cancellationToken = default) => Result(async () => await service.CurrentSessionAsync(accountId, cancellationToken)); + + [McpServerTool(Name = "messages_read", ReadOnly = true), Description("Read bounded visible messages for an explicitly bound account; content requires the content permission.")] + public Task Messages(string accountId, string? session = null, int limit = 50, int offset = 0, bool includeContent = false, CancellationToken cancellationToken = default) => Result(async () => await service.MessagesAsync(accountId, session, limit, offset, includeContent, cancellationToken)); + + [McpServerTool(Name = "contacts_list", ReadOnly = true), Description("Read stable-ID contacts from an explicitly selected read-only database account.")] + public Task Contacts(string accountId, string? contains = null, bool? groupsOnly = null, int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.ContactsAsync(accountId, contains, groupsOnly, limit, offset, cancellationToken)); [McpServerTool(Name = "group_members", ReadOnly = true), Description("Read stable-ID members of an explicitly selected group and account.")] public Task GroupMembers(string accountId, string group, int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.GroupMembersAsync(accountId, group, limit, offset, cancellationToken)); diff --git a/src/WxAgent.Service/ReadOnlyContracts.cs b/src/WxAgent.Service/ReadOnlyContracts.cs index 564fd5f..c6c56d1 100644 --- a/src/WxAgent.Service/ReadOnlyContracts.cs +++ b/src/WxAgent.Service/ReadOnlyContracts.cs @@ -1,7 +1,7 @@ namespace WxAgent.Service; public sealed record Page(IReadOnlyList Items, int Limit, int Offset, bool HasMore, int? NextOffset); -public sealed record AccountInfo(string AccountId, string? DisplayName, string? WechatId, string? Region, string DataFingerprint, bool IsUiBindingKnown); +public sealed record AccountInfo(string AccountId, string? DisplayName, string? WechatId, string? Region, string DataFingerprint, bool IsUiBindingKnown, AccountBinding? Binding = null, string BindingStatus = "Unbound"); public sealed record SessionInfo(string Name, string AutomationId, bool IsCurrent); public sealed record MessageInfo(string Fingerprint, string Type, string? Sender, string? Summary, string? Content); public sealed record ListRequest(int Limit = 50, int Offset = 0, bool IncludeContent = false, string? AccountId = null, string? Session = null); diff --git a/src/WxAgent.Service/ServiceHost.cs b/src/WxAgent.Service/ServiceHost.cs index ec331ff..8426d68 100644 --- a/src/WxAgent.Service/ServiceHost.cs +++ b/src/WxAgent.Service/ServiceHost.cs @@ -30,6 +30,7 @@ public static class ServiceHost builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(p => p.GetRequiredService()); builder.Services.AddHostedService(); @@ -81,12 +82,16 @@ public static class ServiceHost app.MapGet("/api/v1/diagnostics", (AgentService service, CancellationToken ct) => service.DiagnoseAsync(ct)); app.MapGet("/api/v1/capabilities", (AgentService service) => service.Capabilities()); app.MapGet("/api/v1/accounts", (int? limit, int? offset, AgentService service, CancellationToken ct) => service.AccountsAsync(limit ?? 50, offset ?? 0, ct)); - app.MapGet("/api/v1/sessions", (int? limit, int? offset, AgentService service, CancellationToken ct) => service.SessionsAsync(limit ?? 50, offset ?? 0, ct)); - app.MapGet("/api/v1/sessions/search", (string query, bool? exactOnly, int? limit, int? offset, AgentService service, CancellationToken ct) => service.SearchSessionsAsync(query, exactOnly ?? false, limit ?? 50, offset ?? 0, ct)); - app.MapGet("/api/v1/sessions/current", (AgentService service, CancellationToken ct) => service.CurrentSessionAsync(ct)); - app.MapPost("/api/v1/sessions/scroll", (ScrollRequest request, AgentService service, CancellationToken ct) => service.ScrollSessionsAsync(request.Direction, request.Pages, ct)); - app.MapPost("/api/v1/sessions/open", (OpenSessionRequest request, AgentService service, CancellationToken ct) => service.OpenSessionAsync(request.AutomationId, ct)); - app.MapGet("/api/v1/messages", (string? session, int? limit, int? offset, bool? includeContent, AgentService service, CancellationToken ct) => service.MessagesAsync(session, limit ?? 50, offset ?? 0, includeContent ?? false, ct)); + app.MapGet("/api/v1/accounts/bindings", (AgentService service) => service.Bindings()); + app.MapPost("/api/v1/accounts/bind", (BindAccountRequest request, AgentService service, CancellationToken ct) => service.BindAccountAsync(request.AccountId, request.TargetId, ct)); + app.MapPost("/api/v1/accounts/unbind", async (UnbindAccountRequest request, AgentService service, CancellationToken ct) => { await service.UnbindAccountAsync(request.AccountId, ct); return Results.NoContent(); }); + app.MapGet("/api/v1/ui-targets", (AgentService service, CancellationToken ct) => service.UiTargetsAsync(ct)); + app.MapGet("/api/v1/sessions", (string? accountId, int? limit, int? offset, AgentService service, CancellationToken ct) => service.SessionsAsync(accountId, limit ?? 50, offset ?? 0, ct)); + app.MapGet("/api/v1/sessions/search", (string? accountId, string query, bool? exactOnly, int? limit, int? offset, AgentService service, CancellationToken ct) => service.SearchSessionsAsync(accountId, query, exactOnly ?? false, limit ?? 50, offset ?? 0, ct)); + app.MapGet("/api/v1/sessions/current", (string? accountId, AgentService service, CancellationToken ct) => service.CurrentSessionAsync(accountId, ct)); + app.MapPost("/api/v1/sessions/scroll", (ScrollRequest request, AgentService service, CancellationToken ct) => service.ScrollSessionsAsync(request.AccountId, request.Direction, request.Pages, ct)); + app.MapPost("/api/v1/sessions/open", (OpenSessionRequest request, AgentService service, CancellationToken ct) => service.OpenSessionAsync(request.AccountId, request.AutomationId, ct)); + app.MapGet("/api/v1/messages", (string? accountId, string? session, int? limit, int? offset, bool? includeContent, AgentService service, CancellationToken ct) => service.MessagesAsync(accountId, session, limit ?? 50, offset ?? 0, includeContent ?? false, ct)); app.MapGet("/api/v1/contacts", (string? accountId, string? contains, bool? groupsOnly, int? limit, int? offset, AgentService service, CancellationToken ct) => service.ContactsAsync(accountId, contains, groupsOnly, limit ?? 50, offset ?? 0, ct)); app.MapGet("/api/v1/groups/{accountId}/{group}/members", (string accountId, string group, int? limit, int? offset, AgentService service, CancellationToken ct) => service.GroupMembersAsync(accountId, group, limit ?? 50, offset ?? 0, ct)); app.MapGet("/api/v1/db/messages", (string accountId, string chatId, int? limit, int? offset, long? localId, AgentService service, CancellationToken ct) => service.DatabaseMessagesAsync(accountId, chatId, limit ?? 50, offset ?? 0, localId, ct)); @@ -151,6 +156,8 @@ public static class ServiceHost } public sealed record LoginRequest(string Token); - public sealed record ScrollRequest(string Direction, int Pages = 1); - public sealed record OpenSessionRequest(string AutomationId); + public sealed record ScrollRequest(string? AccountId, string Direction, int Pages = 1); + public sealed record OpenSessionRequest(string? AccountId, string AutomationId); + public sealed record BindAccountRequest(string AccountId, string TargetId); + public sealed record UnbindAccountRequest(string AccountId); } diff --git a/src/WxAgent.Service/wwwroot/app.js b/src/WxAgent.Service/wwwroot/app.js index e7c38ff..7b33364 100644 --- a/src/WxAgent.Service/wwwroot/app.js +++ b/src/WxAgent.Service/wwwroot/app.js @@ -1,7 +1,16 @@ const $=id=>document.getElementById(id);let csrf=''; function showError(e){$('error').textContent=e.message||'请求失败'} async function api(path,init={}){const r=await fetch(path,{...init,headers:{'Accept':'application/json',...(init.headers||{})}});if(!r.ok){let x={};try{x=await r.json()}catch{}if(r.status===401){$('app').hidden=true;$('login').hidden=false}throw new Error(x.error?.message||`HTTP ${r.status}`)}return r.status===204?null:r.json()} +async function mutate(path,body){return api(path,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf,'Origin':location.origin},body:JSON.stringify(body)})} function rows(id,items,format){$(id).replaceChildren(...items.map(x=>{const d=document.createElement('div');d.className='row';d.textContent=format(x);return d}))} -async function refresh(){try{const [s,c,a,se,co,m]=await Promise.all([api('/api/v1/status'),api('/api/v1/capabilities'),api('/api/v1/accounts?limit=50'),api('/api/v1/sessions?limit=50'),api('/api/v1/contacts?limit=50'),api(`/api/v1/messages?limit=50&includeContent=${$('content').checked}`)]);$('status').textContent=JSON.stringify(s,null,2);rows('capabilities',c,x=>`${x.operation} — ${x.enabled?'可用':'禁用'}${x.disabledReason?`:${x.disabledReason}`:''}`);rows('accounts',a.items,x=>`${x.accountId} ${x.displayName||''}(数据库指纹不代表 UI 绑定)`);rows('sessions',se.items,x=>`${x.name}${x.isCurrent?'(当前)':''}`);rows('contacts',co.items,x=>`${x.displayName||'[未知]'} — ${x.id}`);rows('messages',m.items,x=>`${x.type} ${x.sender||''}: ${x.content??x.summary??'[正文未授权]'}`)}catch(e){showError(e)}} +function renderBindings(accounts,targets){ + const accountById=new Map(accounts.map(x=>[x.accountId,x])); + const accountRows=accounts.map(account=>{const d=document.createElement('div');d.className='row';d.textContent=`${account.accountId} ${account.bindingStatus|| (account.binding?'Bound':'Unbound')} ${account.binding?`进程 ${account.binding.processId} 窗口 ${account.binding.windowHandle}`:''}`;if(account.binding){const b=document.createElement('button');b.textContent='解绑';b.onclick=async()=>{try{await mutate('/api/v1/accounts/unbind',{accountId:account.accountId});await refresh()}catch(e){showError(e)}};d.append(' ',b)}return d}); + $('accounts').replaceChildren(...accountRows); + const targetRows=targets.map(target=>{const d=document.createElement('div');d.className='row';d.textContent=`${target.targetId} ${target.wechatId||target.nickname||'[身份未读取]'}(PID ${target.processId} / HWND ${target.windowHandle})`;if(target.isBound){d.append(' 已绑定')}else{const select=document.createElement('select');select.append(new Option('选择账号',''),...accounts.filter(x=>!x.binding).map(x=>new Option(x.accountId,x.accountId)));const b=document.createElement('button');b.textContent='绑定';b.onclick=async()=>{if(!select.value)return;try{await mutate('/api/v1/accounts/bind',{accountId:select.value,targetId:target.targetId});await refresh()}catch(e){showError(e)}};d.append(' ',select,' ',b)}return d}); + $('uiTargets').replaceChildren(...targetRows); + if(!accountById.size&&!targets.length){$('uiTargets').textContent='未发现当前交互式会话中的微信主窗口。'} +} +async function refresh(){try{const [s,c,a,t]=await Promise.all([api('/api/v1/status'),api('/api/v1/capabilities'),api('/api/v1/accounts?limit=50'),api('/api/v1/ui-targets')]);$('status').textContent=JSON.stringify(s,null,2);rows('capabilities',c,x=>`${x.operation} — ${x.enabled?'可用':'禁用'}${x.disabledReason?`:${x.disabledReason}`:''}`);const select=$('accountSelect');const old=select.value;select.replaceChildren(new Option('选择已绑定账号',''),...a.items.filter(x=>x.binding).map(x=>new Option(x.accountId,x.accountId)));select.value=a.items.some(x=>x.accountId===old&&x.binding)?old:'';renderBindings(a.items,t);if(!select.value){rows('sessions',[] ,()=> '');rows('contacts',[] ,()=> '');rows('messages',[] ,()=> '');return}const id=`&accountId=${encodeURIComponent(select.value)}`;const [se,co,m]=await Promise.all([api(`/api/v1/sessions?limit=50${id}`),api(`/api/v1/contacts?limit=50${id}`),api(`/api/v1/messages?limit=50&includeContent=${$('content').checked}${id}`)]);rows('sessions',se.items,x=>`${x.name}${x.isCurrent?'(当前)':''}`);rows('contacts',co.items,x=>`${x.displayName||'[未知]'} — ${x.id}`);rows('messages',m.items,x=>`${x.type} ${x.sender||''}: ${x.content??x.summary??'[正文未授权]'}`)}catch(e){showError(e)}} $('loginForm').addEventListener('submit',async e=>{e.preventDefault();try{const x=await api('/api/v1/login',{method:'POST',headers:{'Content-Type':'application/json','Origin':location.origin},body:JSON.stringify({token:$('token').value})});csrf=x.csrfToken;$('token').value='';$('login').hidden=true;$('app').hidden=false;await refresh()}catch(e){showError(e)}}); -$('refresh').onclick=refresh;$('content').onchange=refresh;$('lookup').onclick=async()=>{try{$('operation').textContent=JSON.stringify(await api('/api/v1/operations/'+encodeURIComponent($('operationId').value)),null,2)}catch(e){showError(e)}};$('logout').onclick=async()=>{try{await api('/api/v1/logout',{method:'POST',headers:{'X-CSRF-Token':csrf,'Origin':location.origin}})}finally{$('app').hidden=true;$('login').hidden=false}}; +$('refresh').onclick=refresh;$('accountSelect').onchange=refresh;$('content').onchange=refresh;$('lookup').onclick=async()=>{try{$('operation').textContent=JSON.stringify(await api('/api/v1/operations/'+encodeURIComponent($('operationId').value)),null,2)}catch(e){showError(e)}};$('logout').onclick=async()=>{try{await api('/api/v1/logout',{method:'POST',headers:{'X-CSRF-Token':csrf,'Origin':location.origin}})}finally{$('app').hidden=true;$('login').hidden=false}}; diff --git a/src/WxAgent.Service/wwwroot/index.html b/src/WxAgent.Service/wwwroot/index.html index bcd6033..0b68c57 100644 --- a/src/WxAgent.Service/wwwroot/index.html +++ b/src/WxAgent.Service/wwwroot/index.html @@ -2,5 +2,5 @@ WxAgent

WxAgent 控制台

HTTP 明文不会保护 Token、消息或附件;仅在可信隔离网络使用。

登录

-
+ diff --git a/src/WxAgent.Windows/WechatChatClient.cs b/src/WxAgent.Windows/WechatChatClient.cs index 6919520..37ad7f5 100644 --- a/src/WxAgent.Windows/WechatChatClient.cs +++ b/src/WxAgent.Windows/WechatChatClient.cs @@ -13,6 +13,8 @@ namespace WxAgent.Windows; public static partial class WechatChatClient { + public static IDisposable UseWindowTarget(int processId, long windowHandle) => WechatDoctor.UseWindow(processId, windowHandle); + private static readonly InterprocessCommandGate CommandQueue = CreateCommandGate(); private static InterprocessCommandGate CreateCommandGate() @@ -577,7 +579,7 @@ public static partial class WechatChatClient if (window is null || FindByAutomationId(window, WechatLocators.MainView) is null) { cancellationToken.ThrowIfCancellationRequested(); - if (!WechatTray.TryActivateUia(automation)) WechatTray.Inspect(true, cancellationToken); + if (WechatDoctor.TargetProcessId is null && !WechatTray.TryActivateUia(automation)) WechatTray.Inspect(true, cancellationToken); for (var attempt = 0; attempt < 50; attempt++) { if (cancellationToken.WaitHandle.WaitOne(100)) cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/WxAgent.Windows/WechatDoctor.cs b/src/WxAgent.Windows/WechatDoctor.cs index c086ab8..601e3db 100644 --- a/src/WxAgent.Windows/WechatDoctor.cs +++ b/src/WxAgent.Windows/WechatDoctor.cs @@ -118,10 +118,39 @@ public static class WechatDoctor inputDesktopAvailable); } + private static readonly AsyncLocal TargetProcess = new(); + private static readonly AsyncLocal TargetWindow = new(); + + internal static int? TargetProcessId => TargetProcess.Value; + + internal static IDisposable UseWindow(int processId, long windowHandle) => UseTarget(processId, windowHandle); + + private static IDisposable UseTarget(int? processId, long? windowHandle) + { + var previousProcess = TargetProcess.Value; + var previousWindow = TargetWindow.Value; + TargetProcess.Value = processId; + TargetWindow.Value = windowHandle; + return new TargetScope(previousProcess, previousWindow); + } + + private sealed class TargetScope(int? previousProcess, long? previousWindow) : IDisposable + { + public void Dispose() + { + TargetProcess.Value = previousProcess; + TargetWindow.Value = previousWindow; + } + } + internal static FlaUI.Core.AutomationElements.AutomationElement? FindWechatWindow(AutomationBase automation) { FlaUI.Core.AutomationElements.AutomationElement? fallback = null; + var processId = TargetProcess.Value; + var windowHandle = TargetWindow.Value; var mainWindows = WechatNativeWindow.Enumerate().Where(window => + (processId is null || window.ProcessId == processId) && + (windowHandle is null || window.Handle == windowHandle) && window.Visible && !window.Minimized && WechatLocators.IsNativeMainWindow(window.ClassName, window.Title)); foreach (var native in mainWindows) { diff --git a/src/WxAgent.Windows/WechatNativeWindow.cs b/src/WxAgent.Windows/WechatNativeWindow.cs index 40aa2ff..608bb48 100644 --- a/src/WxAgent.Windows/WechatNativeWindow.cs +++ b/src/WxAgent.Windows/WechatNativeWindow.cs @@ -49,4 +49,8 @@ public static partial class WechatChatClient { public static IReadOnlyList InspectNativeWindows() => WechatNativeWindow.Enumerate().Select(WechatNativeWindow.Sanitize).ToArray(); + + public static IReadOnlyList InspectMainWindows() => + WechatNativeWindow.Enumerate().Where(window => WechatLocators.IsNativeMainWindow(window.ClassName, window.Title)) + .Select(WechatNativeWindow.Sanitize).ToArray(); } diff --git a/tests/WxAgent.Service.Tests/AccountBindingStoreTests.cs b/tests/WxAgent.Service.Tests/AccountBindingStoreTests.cs new file mode 100644 index 0000000..afbe1ea --- /dev/null +++ b/tests/WxAgent.Service.Tests/AccountBindingStoreTests.cs @@ -0,0 +1,30 @@ +using WxAgent.Service; +using Xunit; + +namespace WxAgent.Service.Tests; + +public sealed class AccountBindingStoreTests +{ + [Fact] + public void ReplacesByAccountAndRemovesExplicitBinding() + { + var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + var store = new AccountBindingStore(new ServiceOptions { DataDirectory = directory, CredentialFile = Path.Combine(directory, "credentials.json") }); + store.Replace([ + new AccountBinding("a", 10, 20, "wxid-a", "Alice", DateTimeOffset.UtcNow), + new AccountBinding("a", 11, 21, "wxid-a", "Alice", DateTimeOffset.UtcNow), + new AccountBinding("b", 12, 22, "wxid-b", "Bob", DateTimeOffset.UtcNow) + ]); + + Assert.Equal(11, store.Get("a")!.ProcessId); + Assert.Equal(2, store.ReadAll().Count); + store.Remove("a"); + Assert.Null(store.Get("a")); + Assert.Single(store.ReadAll()); + } + finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); } + } +}