diff --git a/docs/WebUI-MCP-使用说明.md b/docs/WebUI-MCP-使用说明.md index 8f507c7..2be3e96 100644 --- a/docs/WebUI-MCP-使用说明.md +++ b/docs/WebUI-MCP-使用说明.md @@ -24,7 +24,7 @@ Authorization: Bearer ``` -MCP Streamable HTTP 地址为 `/mcp`。不要把 Token 放在 URL、MCP session ID、浏览器持久存储或日志中。后台消息监听默认关闭;启用后会在微信界面上进行消息观察,可在“服务设置...”中显式打开。真机消息验证最多发送 3 条。 +MCP Streamable HTTP 地址为 `/mcp`。不要把 Token 放在 URL、MCP session ID、浏览器持久存储或日志中。后台消息监听默认关闭;启用后会在微信界面上进行消息观察,可在“服务设置...”中显式打开。账号绑定成功后复用已保存的绑定身份,不会在每次监听/只读读取时重新打开设置或个人资料窗口;只有显式绑定或需要重新校验身份时才通过微信头像资料读取一次。真机消息验证最多发送 3 条。 ## 凭据更换与撤销 diff --git a/node-agent/WxAgent.Host/WindowsAgentBackend.cs b/node-agent/WxAgent.Host/WindowsAgentBackend.cs index ac177d6..d0983fb 100644 --- a/node-agent/WxAgent.Host/WindowsAgentBackend.cs +++ b/node-agent/WxAgent.Host/WindowsAgentBackend.cs @@ -56,9 +56,8 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOpt } 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."); + if (string.IsNullOrWhiteSpace(binding.WechatId) && string.IsNullOrWhiteSpace(binding.Nickname)) + throw new ServiceException("AccountBindingStale", 409, "The persisted account binding has no verified identity; bind the account again."); var sessions = (await WechatChatClient.ListVisibleSessionsAsync(cancellationToken)) .Where(session => string.Equals(session.Name, WechatLocators.FileTransferAssistant, StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(session.AutomationId)) @@ -73,9 +72,6 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOpt await foreach (var item in WechatChatClient.ListenEventsAsync(TimeSpan.FromMinutes(5), checkpoint, cancellationToken, session: WechatLocators.FileTransferAssistant)) { - var observedIdentity = await WechatChatClient.GetMyInfoAsync(cancellationToken); - if (!BindingMatches(binding, observedIdentity)) - throw new ServiceException("AccountBindingStale", 409, "The listener binding changed; reporting is paused until the account is rebound."); var eventType = item.Kind == MessageEventKind.MessageReceived ? "message" : "listener.reconnected"; yield return new AgentEvent(item.EventId, binding.AccountId, item.Session, eventType, item.Message is null ? "listener state" : item.Message.Type.ToString(), item.ObservedAt, @@ -352,7 +348,7 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOpt } public Task OpenSessionAsync(string? accountId, string automationId, CancellationToken cancellationToken) => - ForAccountAsync(accountId, cancellationToken, () => OpenSessionAsync(automationId, cancellationToken)); + ForAccountAsync(accountId, cancellationToken, () => OpenSessionAsync(automationId, cancellationToken), validateIdentity: true); public async Task ScrollSessionsAsync(string direction, int pages, CancellationToken cancellationToken) { @@ -362,7 +358,7 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOpt } public Task ScrollSessionsAsync(string? accountId, string direction, int pages, CancellationToken cancellationToken) => - ForAccountAsync(accountId, cancellationToken, () => ScrollSessionsAsync(direction, pages, cancellationToken)); + ForAccountAsync(accountId, cancellationToken, () => ScrollSessionsAsync(direction, pages, cancellationToken), validateIdentity: true); public async Task> MessagesAsync(string? session, bool includeContent, CancellationToken cancellationToken) { @@ -390,10 +386,10 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOpt throw new ServiceException(matches.Length == 0 ? "NotFound" : "AmbiguousTarget", 409, "The selected session is not unique or no longer visible."); await WechatChatClient.SendTextAsync(text, cancellationToken, matches[0].Name); return true; - }); + }, validateIdentity: true); } - private async Task ForAccountAsync(string? accountId, CancellationToken cancellationToken, Func> action) + private async Task ForAccountAsync(string? accountId, CancellationToken cancellationToken, Func> action, bool validateIdentity = false) { if (string.IsNullOrWhiteSpace(accountId)) throw new ServiceException("AccountIdRequired", 400, "accountId is required for every WeChat UI operation."); @@ -408,12 +404,15 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOpt 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."); + if (validateIdentity) + { + 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(); } diff --git a/node-agent/WxAgent.Windows/WechatChatClient.Management.cs b/node-agent/WxAgent.Windows/WechatChatClient.Management.cs index d8cacee..ec5464f 100644 --- a/node-agent/WxAgent.Windows/WechatChatClient.Management.cs +++ b/node-agent/WxAgent.Windows/WechatChatClient.Management.cs @@ -100,40 +100,29 @@ public static partial class WechatChatClient public static Task GetMyInfoAsync(CancellationToken cancellationToken = default) => WithMainWindowAsync(async (main, automation) => { + ClickProfileAvatar(main); var desktop = automation.GetDesktop(); - var settings = FindPreferenceWindow(desktop); - var closeSettings = settings is null; - settings ??= await OpenPreferenceWindowAsync(main, automation, cancellationToken).ConfigureAwait(false); + AutomationElement? profile = null; + for (var attempt = 0; attempt < 30 && profile is null; attempt++) + { + profile = desktop.FindAllChildren(cf => cf.ByControlType(ControlType.Window)) + .FirstOrDefault(window => IsActionable(window) + && string.Equals(SafeName(window), WechatLocators.ProfileWindowTitle, StringComparison.Ordinal) + && FindByAutomationId(window, WechatLocators.ProfileDisplayName) is not null); + if (profile is null) await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + if (profile is null) throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Account profile window was not found."); try { - var accountLabel = settings.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) - .FirstOrDefault(element => SafeName(element) == "账号"); - if (accountLabel is null) - throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Account section was not found in WeChat settings."); - - var labelBounds = accountLabel.BoundingRectangle; - var values = settings.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) - .Select(element => (Name: SafeName(element), Bounds: element.BoundingRectangle)) - .Where(value => !string.IsNullOrWhiteSpace(value.Name) - && value.Bounds.Top >= labelBounds.Bottom - && value.Bounds.Bottom <= labelBounds.Bottom + 90 - && value.Bounds.Left >= labelBounds.Left - && value.Bounds.Right <= labelBounds.Right - 80) - .OrderBy(value => value.Bounds.Top) - .Select(value => value.Name) - .Distinct(StringComparer.Ordinal) - .ToArray(); - if (values.Length < 2) - throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Account identity was not found in WeChat settings."); - return new WechatAccountSnapshot(values[0], values[1]); - } - finally - { - if (closeSettings) - { - try { settings.AsWindow().Close(); } catch { } - } + var displayName = SafeName(FindByAutomationId(profile, WechatLocators.ProfileDisplayName)); + var wechatId = SafeName(FindByAutomationId(profile, WechatLocators.ProfileWechatId)); + if (string.IsNullOrWhiteSpace(displayName) && string.IsNullOrWhiteSpace(wechatId)) + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Account identity was not found in the profile window."); + return new WechatAccountSnapshot( + string.IsNullOrWhiteSpace(displayName) ? wechatId : displayName, + string.IsNullOrWhiteSpace(wechatId) ? null : wechatId); } + finally { profile.AsWindow().Close(); } }, cancellationToken); /// Reads non-group contact rows, up to maxCount. Use the paged API for completeness and account selection. @@ -594,47 +583,18 @@ public static partial class WechatChatClient throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Person checkbox '{name}' was not exposed."); } - private static async Task OpenPreferenceWindowAsync( - AutomationElement main, UIA3Automation automation, CancellationToken cancellationToken) + // WeChat exposes the avatar as a nameless custom widget; use the stable main-tab bar bounds. + private static void ClickProfileAvatar(AutomationElement main) { - var desktop = automation.GetDesktop(); - var menu = FindSettingsMenu(desktop); - if (menu is null) - { - var settingsButton = FindByAutomationId(main, WechatLocators.SettingsMenuButton) - ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat settings button was not found."); - ExecuteInputStep("open-settings-menu", () => settingsButton.Click()); - } - - var clickedSettings = false; - for (var attempt = 0; attempt < 40; attempt++) - { - cancellationToken.ThrowIfCancellationRequested(); - var settings = FindPreferenceWindow(desktop); - if (settings is not null) return settings; - menu ??= FindSettingsMenu(desktop); - if (menu is not null && !clickedSettings) - { - ClickNamed(menu, "设置", ControlType.Button); - clickedSettings = true; - } - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat settings window was not found."); + var tabBar = FindByAutomationId(main, WechatLocators.MainTabBar) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat navigation bar was not found."); + var bounds = tabBar.BoundingRectangle; + if (bounds.Width <= 0 || bounds.Height <= bounds.Width) + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat navigation bar has no usable bounds."); + ExecuteInputStep("open-profile", () => Mouse.Click( + new System.Drawing.Point(bounds.Left + bounds.Width / 2, bounds.Top + bounds.Width), MouseButton.Left)); } - private static AutomationElement? FindPreferenceWindow(AutomationElement desktop) => - desktop.FindAllChildren(cf => cf.ByControlType(ControlType.Window)) - .FirstOrDefault(window => IsActionable(window) - && (SafeAutomationId(window) == WechatLocators.PreferenceWindow - || SafeName(window) == "设置")); - - private static AutomationElement? FindSettingsMenu(AutomationElement desktop) => - desktop.FindAllChildren(cf => cf.ByControlType(ControlType.Window)) - .FirstOrDefault(window => IsActionable(window) - && window.FindAllDescendants(cf => cf.ByControlType(ControlType.Button)) - .Any(button => IsActionable(button) && SafeName(button) == "设置")); - private static bool IsActionable(AutomationElement element) => !element.Properties.IsOffscreen.ValueOrDefault && element.Properties.IsEnabled.ValueOrDefault && !element.BoundingRectangle.IsEmpty;