From 078c22c73bd68d862be70e2f0d85b63de076fdc2 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 11 Sep 2026 11:31:06 +0800 Subject: [PATCH] fix: auto-bind matching WeChat accounts --- src/WxAgent.Host/WindowsAgentBackend.cs | 140 +++++++++++++++---- src/WxAgent.Service/wwwroot/index.html | 2 +- src/WxAgent.Windows/WechatContactDbReader.cs | 26 ++++ 3 files changed, 137 insertions(+), 31 deletions(-) diff --git a/src/WxAgent.Host/WindowsAgentBackend.cs b/src/WxAgent.Host/WindowsAgentBackend.cs index 9755829..5df3d9a 100644 --- a/src/WxAgent.Host/WindowsAgentBackend.cs +++ b/src/WxAgent.Host/WindowsAgentBackend.cs @@ -14,7 +14,7 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings) : IAgentBa 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("account-binding", true, false, true, true, true, "manage", false, 30, "Auto-binds a unique database/UI identity match; manual binding remains available as fallback.", ["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"), @@ -68,31 +68,37 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings) : IAgentBa public async Task> AccountsAsync(CancellationToken cancellationToken) { - var targets = await ReadUiTargetsAsync(cancellationToken, false); - var roots = WechatDatabaseDiscovery.FindAccountRoots(cancellationToken: cancellationToken); + var targets = await ReadUiTargetsAsync(cancellationToken, true); + var accounts = await ReadDatabaseAccountsAsync(cancellationToken); + await AutoBindMatchesAsync(accounts, targets, cancellationToken); var current = bindings.ReadAll(); - var live = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var binding in current) + var live = current.Where(binding => targets.Any(target => + target.ProcessId == binding.ProcessId && target.WindowHandle == binding.WindowHandle && + (string.IsNullOrWhiteSpace(target.WechatId) && string.IsNullOrWhiteSpace(target.Nickname) || + BindingMatches(binding, new WechatAccountSnapshot(target.Nickname ?? string.Empty, target.WechatId))))) + .Select(binding => binding.AccountId) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return accounts.Select(account => { - 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 binding = bindings.Get(account.AccountId); 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"); + return new AccountInfo(account.AccountId, account.Identity?.Nickname, account.Identity?.WechatId, null, + account.AccountId, isLive, binding, binding is null ? "Unbound" : isLive ? "Bound" : "Stale"); }).ToArray(); } - public Task> UiTargetsAsync(CancellationToken cancellationToken) => - ReadUiTargetsAsync(cancellationToken, true); + public async Task> UiTargetsAsync(CancellationToken cancellationToken) + { + var targets = await ReadUiTargetsAsync(cancellationToken, true); + var accounts = await ReadDatabaseAccountsAsync(cancellationToken); + await AutoBindMatchesAsync(accounts, targets, cancellationToken); + var bound = bindings.ReadAll().Select(binding => (binding.ProcessId, binding.WindowHandle)) + .ToHashSet(new BindingTargetComparer()); + return targets.Select(target => target with + { + IsBound = bound.Contains((target.ProcessId, target.WindowHandle)) + }).ToArray(); + } private async Task> ReadUiTargetsAsync(CancellationToken cancellationToken, bool readIdentity) { @@ -124,14 +130,20 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings) : IAgentBa { 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); + var target = (await ReadUiTargetsAsync(cancellationToken, true)).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)) + if (!string.IsNullOrWhiteSpace(target.WechatId) || !string.IsNullOrWhiteSpace(target.Nickname)) + ui = new WechatAccountSnapshot(target.Nickname ?? string.Empty, target.WechatId); + else + { + using var scope = WechatChatClient.UseWindowTarget(processId, windowHandle); ui = await WechatChatClient.GetMyInfoAsync(cancellationToken); - var databaseIdentity = await TryReadDatabaseIdentityAsync(account, ui, cancellationToken) + } + var databaseIdentity = await TryReadDatabaseIdentityAsync(account, cancellationToken) + ?? 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)) @@ -154,8 +166,8 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings) : IAgentBa 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); + 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; } @@ -183,7 +195,73 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings) : IAgentBa processId > 0 && windowHandle > 0; } - private static async Task TryReadDatabaseIdentityAsync(AccountKeySet account, WechatAccountSnapshot ui, CancellationToken cancellationToken) + private async Task> ReadDatabaseAccountsAsync(CancellationToken cancellationToken) + { + var roots = WechatDatabaseDiscovery.FindAccountRoots(cancellationToken: cancellationToken); + var accounts = new List(roots.Count); + foreach (var root in roots) + { + DatabaseAccountIdentity? identity = null; + try + { + var account = await WechatContactDbReader.LoadAccountAsync(root.Fingerprint, cancellationToken: cancellationToken); + identity = await TryReadDatabaseIdentityAsync(account, cancellationToken); + } + catch (WxAgentException) { } + accounts.Add(new(root.Fingerprint, identity)); + } + return accounts; + } + + private async Task AutoBindMatchesAsync(IReadOnlyList accounts, + IReadOnlyList targets, CancellationToken cancellationToken) + { + if (accounts.All(account => account.Identity is null) || targets.Count == 0) return; + await bindingGate.WaitAsync(cancellationToken); + try + { + var current = bindings.ReadAll().ToList(); + var changed = false; + foreach (var target in targets.Where(target => !string.IsNullOrWhiteSpace(target.WechatId) || !string.IsNullOrWhiteSpace(target.Nickname))) + { + var existing = current.FirstOrDefault(binding => binding.ProcessId == target.ProcessId && binding.WindowHandle == target.WindowHandle); + var available = accounts.Where(account => account.Identity is not null && + (existing is not null && string.Equals(existing.AccountId, account.AccountId, StringComparison.OrdinalIgnoreCase) || + !current.Any(binding => string.Equals(binding.AccountId, account.AccountId, StringComparison.OrdinalIgnoreCase)))) + .Select(account => account.Identity!) + .ToArray(); + var match = AccountBindingMatcher.Match(new UiAccountIdentity(target.WechatId, target.Nickname), available); + if (!match.IsMatched) continue; + var identity = available.Single(account => string.Equals(account.AccountId, match.AccountId, StringComparison.OrdinalIgnoreCase)); + var binding = new AccountBinding(identity.AccountId, target.ProcessId, target.WindowHandle, + target.WechatId, target.Nickname ?? string.Empty, existing?.BoundAt ?? DateTimeOffset.UtcNow); + if (existing is null) + { + current.Add(binding); + changed = true; + } + else if (existing.AccountId.Equals(binding.AccountId, StringComparison.OrdinalIgnoreCase) && + (!string.Equals(existing.WechatId, binding.WechatId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(existing.Nickname, binding.Nickname, StringComparison.Ordinal))) + { + current[current.IndexOf(existing)] = binding; + changed = true; + } + } + if (changed) bindings.Replace(current); + } + finally { bindingGate.Release(); } + } + + private static async Task TryReadDatabaseIdentityAsync(AccountKeySet account, + CancellationToken cancellationToken) + { + try { return await WechatContactDbReader.ReadAccountIdentityAsync(account, cancellationToken); } + catch (WxAgentException) { return null; } + } + + private static async Task TryReadDatabaseIdentityAsync(AccountKeySet account, + WechatAccountSnapshot ui, CancellationToken cancellationToken) { try { @@ -191,22 +269,24 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings) : IAgentBa { 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 (exact.Length == 1) + return new DatabaseAccountIdentity(account.AccountRootFingerprint, exact[0].Username, exact[0].DisplayName); } 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); + if (exact.Length == 1) + return new DatabaseAccountIdentity(account.AccountRootFingerprint, exact[0].Username, exact[0].DisplayName); } return null; } catch (WxAgentException) { return null; } } + private sealed record DatabaseAccount(string AccountId, DatabaseAccountIdentity? Identity); + private sealed class BindingTargetComparer : IEqualityComparer<(int ProcessId, long WindowHandle)> { public bool Equals((int ProcessId, long WindowHandle) x, (int ProcessId, long WindowHandle) y) => x == y; diff --git a/src/WxAgent.Service/wwwroot/index.html b/src/WxAgent.Service/wwwroot/index.html index c4f3c87..b870335 100644 --- a/src/WxAgent.Service/wwwroot/index.html +++ b/src/WxAgent.Service/wwwroot/index.html @@ -79,7 +79,7 @@

能力

按当前凭据显示

账号绑定

数据库账号 ↔ 微信窗口
-

微信窗口

显式选择后绑定
+

微信窗口

自动匹配;失败时手动绑定
diff --git a/src/WxAgent.Windows/WechatContactDbReader.cs b/src/WxAgent.Windows/WechatContactDbReader.cs index 2174283..a690458 100644 --- a/src/WxAgent.Windows/WechatContactDbReader.cs +++ b/src/WxAgent.Windows/WechatContactDbReader.cs @@ -16,6 +16,32 @@ public static class WechatContactDbReader return matches[0]; } + public static async Task ReadAccountIdentityAsync(AccountKeySet account, + CancellationToken cancellationToken = default) + { + var storageDirectory = new DirectoryInfo(account.AccountRootPath); + var directoryName = storageDirectory.Name.Equals("db_storage", StringComparison.OrdinalIgnoreCase) + ? storageDirectory.Parent?.Name ?? storageDirectory.Name + : storageDirectory.Name; + var separator = directoryName.LastIndexOf('_'); + var candidates = new[] + { + directoryName, + separator > 0 ? directoryName[..separator] : directoryName + }.Where(candidate => !string.IsNullOrWhiteSpace(candidate)).Distinct(StringComparer.OrdinalIgnoreCase); + foreach (var candidate in candidates) + { + var page = await ReadPageAsync(account, 10000, 0, candidate, false, cancellationToken).ConfigureAwait(false); + var exact = page.Contacts.Where(contact => string.Equals(contact.Username, candidate, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (exact.Length == 1) + { + var contact = exact[0]; + return new DatabaseAccountIdentity(account.AccountRootFingerprint, contact.Username, contact.DisplayName); + } + } + return null; + } + public static async Task ReadPageAsync(AccountKeySet account, int limit = 200, int offset = 0, string? contains = null, bool? groupsOnly = null, CancellationToken cancellationToken = default) {