Files
wx-win-agent/node-agent/WxAgent.Host/WindowsAgentBackend.cs
T
rogee c7c0ab273f
Build web service image / build (push) Successful in 1m9s
feat: validate single-client broadcast operations
2026-09-19 14:33:26 +08:00

475 lines
31 KiB
C#

using WxAgent.Core;
using WxAgent.Service;
using WxAgent.Windows;
namespace WxAgent.Host;
public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOptions options) : IAgentBackend, IAgentEventSource
{
private const bool ListenerEventsEnabled = true;
private readonly bool validationOperationsEnabled = options.EnableValidationOperations;
private readonly SemaphoreSlim bindingGate = new(1, 1);
public IReadOnlyList<AgentCapability> Capabilities =>
[
new("agent-status", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("agent-diagnose", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("accounts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
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-single-client-2026-09-19.md"], "4.1.13.65"),
new("sessions-search", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("session-current", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("sessions-scroll", true, false, validationOperationsEnabled, true, false, "manage", false, 30,
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["Controlled validation mode."], "4.1.13.65"),
new("session-open", true, false, validationOperationsEnabled, true, true, "manage", false, 30,
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["Controlled validation mode."], "4.1.13.65"),
new("messages-read", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("contacts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("db-messages", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.65"),
new("db-merged", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.65"),
new("group-members", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("listener-events", true, ListenerEventsEnabled, ListenerEventsEnabled, true, false, "read", false, 30,
ListenerEventsEnabled ? null : "Listener events are reserved for controlled validation.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("send-text", true, true, validationOperationsEnabled, true, true, "write", true, 30,
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("broadcast-text", true, true, validationOperationsEnabled, true, true, "write", true, 300,
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("group-at-all", true, false, false, true, true, "write", true, 30,
"No group-at-all operation is registered; validate group targets through confirmed send-text tasks.", []),
new("next-unread", false, false, false, true, false, "read", false, 30,
"Deferred by docs/PENDING.md.", [])
];
public async IAsyncEnumerable<AgentEvent> ListenAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var active = bindings.ReadAll();
if (active.Count != 1)
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
yield break;
}
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.");
var sessions = (await WechatChatClient.ListVisibleSessionsAsync(cancellationToken))
.Where(session => string.Equals(session.Name, WechatLocators.FileTransferAssistant, StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(session.AutomationId))
.ToArray();
if (sessions.Length != 1)
throw new ServiceException("ChatIdentityUnconfirmed", 409, "The listener chat identity could not be uniquely confirmed.");
var chatId = sessions[0].AutomationId;
var accountTag = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(binding.AccountId)))[..16].ToLowerInvariant();
var checkpoint = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WxAgent", $"listener-{accountTag}.json");
while (!cancellationToken.IsCancellationRequested)
{
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,
chatId, ReportingChatType.Private, item.Message?.Text);
}
}
}
private static bool BindingMatches(AccountBinding binding, WechatAccountSnapshot current) =>
!string.IsNullOrWhiteSpace(binding.WechatId)
? string.Equals(binding.WechatId, current.WechatId, StringComparison.OrdinalIgnoreCase)
: string.Equals(binding.Nickname, current.DisplayName, StringComparison.Ordinal);
public async Task<IReadOnlyList<AccountInfo>> AccountsAsync(CancellationToken cancellationToken)
{
var targets = await ReadUiTargetsAsync(cancellationToken, true);
var accounts = await ReadDatabaseAccountsAsync(cancellationToken);
await AutoBindMatchesAsync(accounts, targets, cancellationToken);
var current = bindings.ReadAll();
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 =>
{
var binding = bindings.Get(account.AccountId);
var isLive = binding is not null && live.Contains(binding.AccountId);
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 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)
{
var bound = bindings.ReadAll().Select(binding => (binding.ProcessId, binding.WindowHandle)).ToHashSet(new BindingTargetComparer());
var targets = new List<UiTargetInfo>();
foreach (var window in WechatChatClient.InspectMainWindows().Where(window => window.Visible && !window.Minimized))
{
cancellationToken.ThrowIfCancellationRequested();
string? wechatId = null;
string? nickname = null;
if (readIdentity)
{
try
{
using var scope = WechatChatClient.UseWindowTarget(window.ProcessId, window.Handle);
var identity = await WechatChatClient.GetMyInfoAsync(cancellationToken);
wechatId = identity.WechatId;
nickname = identity.DisplayName;
}
catch (WxAgentException) { }
}
targets.Add(new UiTargetInfo(TargetId(window.ProcessId, window.Handle), window.ProcessId, window.Handle,
window.Title, wechatId, nickname, bound.Contains((window.ProcessId, window.Handle))));
}
return targets;
}
public async Task<AccountBinding> BindAccountAsync(string accountId, string targetId, CancellationToken cancellationToken)
{
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, 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;
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, 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))
throw new ServiceException("AccountBindingMismatch", 409, "The selected WeChat window does not match the selected database account.");
await bindingGate.WaitAsync(cancellationToken);
try
{
var current = bindings.ReadAll();
var accountMatches = current.Where(x => string.Equals(x.AccountId, account.AccountRootFingerprint, StringComparison.OrdinalIgnoreCase)).ToArray();
if (accountMatches.Length > 1)
throw new ServiceException("BindingAmbiguous", 409, "The persisted account binding is ambiguous; remove account-bindings.json and bind again.");
var accountBinding = accountMatches.SingleOrDefault();
if (accountBinding is not null && (accountBinding.ProcessId != processId || accountBinding.WindowHandle != windowHandle))
throw new ServiceException("AccountAlreadyBound", 409, "The account is already bound to another WeChat window; unbind it first.");
var targetMatches = current.Where(x => x.ProcessId == processId && x.WindowHandle == windowHandle).ToArray();
if (targetMatches.Length > 1)
throw new ServiceException("BindingAmbiguous", 409, "The persisted window binding is ambiguous; remove account-bindings.json and bind again.");
var targetBinding = targetMatches.SingleOrDefault();
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);
bindings.Replace(current.Where(x => !string.Equals(x.AccountId, binding.AccountId, StringComparison.OrdinalIgnoreCase)).Append(binding));
return binding;
}
finally { bindingGate.Release(); }
}
public async Task UnbindAccountAsync(string accountId, CancellationToken cancellationToken)
{
await bindingGate.WaitAsync(cancellationToken);
try { bindings.Remove(accountId); }
finally { bindingGate.Release(); }
}
private static string TargetId(int processId, long windowHandle) =>
$"{processId.ToString(System.Globalization.CultureInfo.InvariantCulture)}:{windowHandle.ToString(System.Globalization.CultureInfo.InvariantCulture)}";
private static bool TryParseTargetId(string targetId, out int processId, out long windowHandle)
{
processId = 0;
windowHandle = 0;
var parts = targetId.Split(':', 2);
return parts.Length == 2 && int.TryParse(parts[0], System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture, out processId) &&
long.TryParse(parts[1], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out windowHandle) &&
processId > 0 && windowHandle > 0;
}
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
{
if (!string.IsNullOrWhiteSpace(ui.WechatId))
{
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 (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 (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;
public int GetHashCode((int ProcessId, long WindowHandle) value) => HashCode.Combine(value.ProcessId, value.WindowHandle);
}
public async Task<IReadOnlyList<SessionInfo>> SessionsAsync(CancellationToken cancellationToken)
{
var sessions = await WechatChatClient.ListVisibleSessionsAsync(cancellationToken);
return sessions.Select(s => new SessionInfo(s.Name, s.AutomationId, s.IsCurrent)).ToArray();
}
public Task<IReadOnlyList<SessionInfo>> SessionsAsync(string? accountId, CancellationToken cancellationToken) =>
ForAccountAsync(accountId, cancellationToken, () => SessionsAsync(cancellationToken));
public async Task<IReadOnlyList<SessionSearchInfo>> SearchSessionsAsync(string query, bool exactOnly, CancellationToken cancellationToken)
{
var results = await WechatChatClient.SearchSessionsAsync(query, exactOnly, cancellationToken);
return results.Select(s => new SessionSearchInfo(s.Name, s.AutomationId, s.IsExactMatch)).ToArray();
}
public Task<IReadOnlyList<SessionSearchInfo>> SearchSessionsAsync(string? accountId, string query, bool exactOnly, CancellationToken cancellationToken) =>
ForAccountAsync(accountId, cancellationToken, () => SearchSessionsAsync(query, exactOnly, cancellationToken));
public async Task<SessionInfo?> CurrentSessionAsync(CancellationToken cancellationToken)
{
var current = await WechatChatClient.GetCurrentSessionAsync(cancellationToken);
return current is null ? null : new SessionInfo(current.Name, current.AutomationId, true);
}
public Task<SessionInfo?> CurrentSessionAsync(string? accountId, CancellationToken cancellationToken) =>
ForAccountAsync(accountId, cancellationToken, () => CurrentSessionAsync(cancellationToken));
public async Task<SessionInfo> OpenSessionAsync(string automationId, CancellationToken cancellationToken)
{
var matches = (await WechatChatClient.ListVisibleSessionsAsync(cancellationToken)).Where(s => s.AutomationId == automationId).ToArray();
if (matches.Length != 1) throw new ServiceException(matches.Length == 0 ? "NotFound" : "AmbiguousTarget", 409, "AutomationId is not unique.");
await WechatChatClient.OpenSessionAsync(matches[0].Name, cancellationToken, automationId);
return new SessionInfo(matches[0].Name, matches[0].AutomationId, true);
}
public Task<SessionInfo> OpenSessionAsync(string? accountId, string automationId, CancellationToken cancellationToken) =>
ForAccountAsync(accountId, cancellationToken, () => OpenSessionAsync(automationId, cancellationToken));
public async Task<SessionViewportInfo> ScrollSessionsAsync(string direction, int pages, CancellationToken cancellationToken)
{
var result = await WechatChatClient.ScrollSessionsAsync(direction == "up" ? WechatScrollDirection.Up : WechatScrollDirection.Down, pages, cancellationToken);
return new SessionViewportInfo(result.Scrolls, result.ViewportChanged,
result.Sessions.Select(s => new SessionInfo(s.Name, s.AutomationId, s.IsCurrent)).ToArray());
}
public Task<SessionViewportInfo> ScrollSessionsAsync(string? accountId, string direction, int pages, CancellationToken cancellationToken) =>
ForAccountAsync(accountId, cancellationToken, () => ScrollSessionsAsync(direction, pages, cancellationToken));
public async Task<IReadOnlyList<MessageInfo>> MessagesAsync(string? session, bool includeContent, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(session))
{
var matches = await WechatChatClient.SearchSessionsAsync(session, exactOnly: true, cancellationToken: cancellationToken);
if (matches.Count != 1) throw new ServiceException(matches.Count == 0 ? "NotFound" : "AmbiguousTarget", 409, "Session name is not unique or not found.");
await WechatChatClient.OpenSessionAsync(matches[0].Name, cancellationToken, session);
}
var messages = await WechatChatClient.ReadVisibleAsync(cancellationToken);
return messages.Select(m => new MessageInfo(m.Fingerprint, m.Type.ToString(), m.Quote?.Sender,
m.Text.Length > 160 ? m.Text[..160] : m.Text, includeContent ? m.Text : null)).ToArray();
}
public Task<IReadOnlyList<MessageInfo>> MessagesAsync(string? accountId, string? session, bool includeContent, CancellationToken cancellationToken) =>
ForAccountAsync(accountId, cancellationToken, () => MessagesAsync(session, includeContent, cancellationToken));
public async Task SendTextAsync(string accountId, string targetId, string text, CancellationToken cancellationToken)
{
await ForAccountAsync(accountId, cancellationToken, async () =>
{
var matches = (await WechatChatClient.ListVisibleSessionsAsync(cancellationToken))
.Where(session => string.Equals(session.AutomationId, targetId, StringComparison.Ordinal)).ToArray();
if (matches.Length != 1)
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;
});
}
private async Task<T> ForAccountAsync<T>(string? accountId, CancellationToken cancellationToken, Func<Task<T>> action)
{
if (string.IsNullOrWhiteSpace(accountId))
throw new ServiceException("AccountIdRequired", 400, "accountId is required for every WeChat UI operation.");
var matches = bindings.ReadAll().Where(x => string.Equals(x.AccountId, accountId, StringComparison.OrdinalIgnoreCase)).ToArray();
if (matches.Length > 1)
throw new ServiceException("BindingAmbiguous", 409, "The persisted account binding is ambiguous; bind state must be repaired before use.");
var binding = matches.SingleOrDefault();
if (binding is null)
throw new ServiceException("AccountNotBound", 409, "The account is not explicitly bound to a WeChat window.");
var target = (await ReadUiTargetsAsync(cancellationToken, false)).SingleOrDefault(x =>
x.ProcessId == binding.ProcessId && x.WindowHandle == binding.WindowHandle);
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.");
return await action();
}
public async Task<Page<ContactInfo>> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(accountId))
throw new ServiceException("AccountIdRequired", 400, "accountId is required for database contact operations.");
var page = await WechatChatClient.GetContactsPageAsync(limit, offset, contains, groupsOnly, accountId, cancellationToken: cancellationToken);
var items = page.Contacts.Select(c => new ContactInfo(c.Username, c.DisplayName, c.Remark, null)).ToArray();
return new Page<ContactInfo>(items, limit, offset, page.HasMore, page.NextOffset);
}
public async Task<Page<GroupMemberInfo>> GroupMembersAsync(string accountId, string group, int limit, int offset, CancellationToken cancellationToken)
{
var page = await WechatChatClient.GetGroupMembersPageAsync(group, limit, offset, accountId, cancellationToken: cancellationToken);
var items = page.Members.Select(m => new GroupMemberInfo(m.MemberId, m.Username, m.DisplayName, m.IsOwner)).ToArray();
return new Page<GroupMemberInfo>(items, limit, offset, page.HasMore, page.NextOffset);
}
public async Task<IReadOnlyList<DatabaseMessageInfo>> DatabaseMessagesAsync(string accountId, string chatId, int limit, long? localId, CancellationToken cancellationToken)
{
var account = await DatabaseKeyStore.LoadAsync(null, cancellationToken);
var selected = account.SingleOrDefault(a => string.Equals(a.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase))
?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "No verified database account matches the selected fingerprint.");
var messages = await WechatMessageDbReader.ReadAsync(selected.AccountRootPath, selected.Databases, chatId, limit, cancellationToken, localId);
return messages.Select(m => new DatabaseMessageInfo(m.LocalId, m.ServerId, m.ChatId, m.SenderWxId, m.SenderName, m.Type, m.Content, m.Timestamp, m.IsSelf)).ToArray();
}
public async Task<MergedMessageInfo> DatabaseMergedAsync(string accountId, string chatId, long localId, CancellationToken cancellationToken)
{
var account = await DatabaseKeyStore.LoadAsync(null, cancellationToken);
var selected = account.SingleOrDefault(a => string.Equals(a.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase))
?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "No verified database account matches the selected fingerprint.");
var merged = await WechatMessageDbReader.ReadMergedAsync(selected.AccountRootPath, selected.Databases, chatId, localId, cancellationToken);
var parent = merged.Parent;
var messages = merged.Record.Messages.Select(m => new MergedMessagePart(m.SenderName, m.Text, m.Timestamp, m.Path, m.DataType)).ToArray();
return new MergedMessageInfo(merged.DatabaseRelativePath,
new DatabaseMessageInfo(parent.LocalId, parent.ServerId, parent.ChatId, parent.SenderWxId, parent.SenderName, parent.Type, parent.Content, parent.Timestamp, parent.IsSelf),
merged.Record.Title, merged.Record.Description, messages);
}
public async Task<object> DiagnoseAsync(CancellationToken cancellationToken)
{
var result = await StatusAsync(cancellationToken);
return new { diagnostic = true, redacted = true, result };
}
public Task<object> StatusAsync(CancellationToken cancellationToken)
{
var report = WechatDoctor.Run(cancellationToken);
return Task.FromResult<object>(new
{
serviceOnline = true,
wechatAvailable = report.Errors.Count == 0,
sessionAvailable = report.UserInteractive && report.InputDesktopAvailable,
sessionLocked = report.Errors.Contains(WxAgentErrorCode.SessionLocked),
report.WindowFound,
errors = report.Errors.Select(e => e.ToString()),
wechatVersions = report.Processes.Select(p => p.Version).Where(v => v is not null).Distinct(),
activeAccountBound = bindings.ReadAll().Count != 0,
defaultReadOnly = true
});
}
}