fix: auto-bind matching WeChat accounts
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user