fix: auto-bind matching WeChat accounts

This commit is contained in:
2026-09-11 11:31:06 +08:00
parent bfe349cc10
commit 078c22c73b
3 changed files with 137 additions and 31 deletions
+110 -30
View File
@@ -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<IReadOnlyList<AccountInfo>> 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<string>(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<IReadOnlyList<UiTargetInfo>> UiTargetsAsync(CancellationToken cancellationToken) =>
ReadUiTargetsAsync(cancellationToken, true);
public async Task<IReadOnlyList<UiTargetInfo>> 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<IReadOnlyList<UiTargetInfo>> 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<DatabaseAccountIdentity?> TryReadDatabaseIdentityAsync(AccountKeySet account, WechatAccountSnapshot ui, CancellationToken cancellationToken)
private async Task<IReadOnlyList<DatabaseAccount>> ReadDatabaseAccountsAsync(CancellationToken cancellationToken)
{
var roots = WechatDatabaseDiscovery.FindAccountRoots(cancellationToken: cancellationToken);
var accounts = new List<DatabaseAccount>(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<DatabaseAccount> accounts,
IReadOnlyList<UiTargetInfo> 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<DatabaseAccountIdentity?> TryReadDatabaseIdentityAsync(AccountKeySet account,
CancellationToken cancellationToken)
{
try { return await WechatContactDbReader.ReadAccountIdentityAsync(account, cancellationToken); }
catch (WxAgentException) { return null; }
}
private static async Task<DatabaseAccountIdentity?> 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;
+1 -1
View File
@@ -79,7 +79,7 @@
<section class="panel"><div class="section-heading"><h2>能力</h2><span class="muted">按当前凭据显示</span></div><div id="capabilities" class="list"></div></section>
<div class="dashboard-grid">
<section class="panel"><div class="section-heading"><h2>账号绑定</h2><span class="muted">数据库账号 ↔ 微信窗口</span></div><div id="accounts" class="list"></div></section>
<section class="panel"><div class="section-heading"><h2>微信窗口</h2><span class="muted">显式选择后绑定</span></div><div id="uiTargets" class="list"></div></section>
<section class="panel"><div class="section-heading"><h2>微信窗口</h2><span class="muted">自动匹配;失败时手动绑定</span></div><div id="uiTargets" class="list"></div></section>
</div>
</section>
</section>
@@ -16,6 +16,32 @@ public static class WechatContactDbReader
return matches[0];
}
public static async Task<DatabaseAccountIdentity?> 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<WechatContactPage> ReadPageAsync(AccountKeySet account, int limit = 200, int offset = 0,
string? contains = null, bool? groupsOnly = null, CancellationToken cancellationToken = default)
{