fix: cache bound identity outside UI actions
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
MCP Streamable HTTP 地址为 `/mcp`。不要把 Token 放在 URL、MCP session ID、浏览器持久存储或日志中。后台消息监听默认关闭;启用后会在微信界面上进行消息观察,可在“服务设置...”中显式打开。真机消息验证最多发送 3 条。
|
||||
MCP Streamable HTTP 地址为 `/mcp`。不要把 Token 放在 URL、MCP session ID、浏览器持久存储或日志中。后台消息监听默认关闭;启用后会在微信界面上进行消息观察,可在“服务设置...”中显式打开。账号绑定成功后复用已保存的绑定身份,不会在每次监听/只读读取时重新打开设置或个人资料窗口;只有显式绑定或需要重新校验身份时才通过微信头像资料读取一次。真机消息验证最多发送 3 条。
|
||||
|
||||
## 凭据更换与撤销
|
||||
|
||||
|
||||
@@ -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<SessionInfo> OpenSessionAsync(string? accountId, string automationId, CancellationToken cancellationToken) =>
|
||||
ForAccountAsync(accountId, cancellationToken, () => OpenSessionAsync(automationId, cancellationToken));
|
||||
ForAccountAsync(accountId, cancellationToken, () => OpenSessionAsync(automationId, cancellationToken), validateIdentity: true);
|
||||
|
||||
public async Task<SessionViewportInfo> ScrollSessionsAsync(string direction, int pages, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -362,7 +358,7 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOpt
|
||||
}
|
||||
|
||||
public Task<SessionViewportInfo> 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<IReadOnlyList<MessageInfo>> 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<T> ForAccountAsync<T>(string? accountId, CancellationToken cancellationToken, Func<Task<T>> action)
|
||||
private async Task<T> ForAccountAsync<T>(string? accountId, CancellationToken cancellationToken, Func<Task<T>> 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -100,40 +100,29 @@ public static partial class WechatChatClient
|
||||
public static Task<WechatAccountSnapshot> 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);
|
||||
|
||||
/// <summary>Reads non-group contact rows, up to maxCount. Use the paged API for completeness and account selection.</summary>
|
||||
@@ -594,47 +583,18 @@ public static partial class WechatChatClient
|
||||
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Person checkbox '{name}' was not exposed.");
|
||||
}
|
||||
|
||||
private static async Task<AutomationElement> 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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user