using WxAgent.Core; using WxAgent.Service; using WxAgent.Windows; namespace WxAgent.Host; public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOptions options) : IAgentBackend, IAgentEventSource, IRemoteDataCollector { private readonly bool validationOperationsEnabled = options.EnableValidationOperations; private readonly bool listenerEventsEnabled = options.EnableListenerEvents; private readonly SemaphoreSlim bindingGate = new(1, 1); private readonly DatabaseMessageSyncCollector databaseSyncCollector = new(null, options.DataSyncBatchLimit, options.DataSyncOverlapRows); public IReadOnlyList 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, true, true, false, false, "read", false, 30, null, ["docs/validation/WxAgent-会话消息同步-P4-验收记录.md", "docs/validation/WxAgent-会话消息同步-P4-live-evidence.json"], "4.1.13.65"), new("db-merged", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WxAgent-会话消息同步-P4-验收记录.md", "docs/validation/WxAgent-会话消息同步-P4-live-evidence.json"], "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 : "Background UI listener is disabled; enable it explicitly in the Tray settings window.", ["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 ListenAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { if (!listenerEventsEnabled) { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); yield break; } 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); 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)) .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 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 Task> AccountsAsync(CancellationToken cancellationToken) => AccountsAsync(cancellationToken, refreshUiIdentity: true); public async Task> AccountsAsync(CancellationToken cancellationToken, bool refreshUiIdentity) { var targets = await ReadUiTargetsAsync(cancellationToken, refreshUiIdentity); var accounts = await ReadDatabaseAccountsAsync(cancellationToken); if (refreshUiIdentity) await AutoBindMatchesAsync(accounts, targets, cancellationToken); var current = bindings.ReadAll(); var singleBoundTarget = !refreshUiIdentity && current.Count == 1 && targets.Count == 1; var live = current.Where(binding => targets.Any(target => (target.ProcessId == binding.ProcessId && target.WindowHandle == binding.WindowHandle && (!refreshUiIdentity || string.IsNullOrWhiteSpace(target.WechatId) && string.IsNullOrWhiteSpace(target.Nickname) || BindingMatches(binding, new WechatAccountSnapshot(target.Nickname ?? string.Empty, target.WechatId)))) || singleBoundTarget)) .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> 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) { var bound = bindings.ReadAll().Select(binding => (binding.ProcessId, binding.WindowHandle)).ToHashSet(new BindingTargetComparer()); var targets = new List(); 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 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> 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 { 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> SessionsAsync(CancellationToken cancellationToken) { var sessions = await WechatChatClient.ListVisibleSessionsAsync(cancellationToken); return sessions.Select(s => new SessionInfo(s.Name, s.AutomationId, s.IsCurrent)).ToArray(); } public Task> SessionsAsync(string? accountId, CancellationToken cancellationToken) => ForAccountAsync(accountId, cancellationToken, () => SessionsAsync(cancellationToken)); public async Task> 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> SearchSessionsAsync(string? accountId, string query, bool exactOnly, CancellationToken cancellationToken) => ForAccountAsync(accountId, cancellationToken, () => SearchSessionsAsync(query, exactOnly, cancellationToken)); public async Task CurrentSessionAsync(CancellationToken cancellationToken) { var current = await WechatChatClient.GetCurrentSessionAsync(cancellationToken); return current is null ? null : new SessionInfo(current.Name, current.AutomationId, true); } public Task CurrentSessionAsync(string? accountId, CancellationToken cancellationToken) => ForAccountAsync(accountId, cancellationToken, () => CurrentSessionAsync(cancellationToken)); public async Task 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 OpenSessionAsync(string? accountId, string automationId, CancellationToken cancellationToken) => ForAccountAsync(accountId, cancellationToken, () => OpenSessionAsync(automationId, cancellationToken), validateIdentity: true); public async Task 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 ScrollSessionsAsync(string? accountId, string direction, int pages, CancellationToken cancellationToken) => ForAccountAsync(accountId, cancellationToken, () => ScrollSessionsAsync(direction, pages, cancellationToken), validateIdentity: true); public async Task> MessagesAsync(string? session, bool includeContent, CancellationToken cancellationToken) { if (!string.IsNullOrWhiteSpace(session)) { var requestedName = session.StartsWith("session_item_", StringComparison.Ordinal) ? session["session_item_".Length..] : session; await WechatChatClient.OpenSessionAsync(requestedName, cancellationToken); } 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> 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; }, validateIdentity: true); } private async Task ForAccountAsync(string? accountId, CancellationToken cancellationToken, Func> action, bool validateIdentity = false) { 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); 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(); } public async Task> 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(items, limit, offset, page.HasMore, page.NextOffset); } public async Task> 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(items, limit, offset, page.HasMore, page.NextOffset); } public Task CollectAsync( string accountId, IReadOnlyList scopes, RemoteDataSyncCheckpoint checkpoint, CancellationToken cancellationToken) => databaseSyncCollector.CollectAsync(accountId, scopes, checkpoint, cancellationToken); public async Task> 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 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 DiagnoseAsync(CancellationToken cancellationToken) { var result = await StatusAsync(cancellationToken); return new { diagnostic = true, redacted = true, result }; } public Task StatusAsync(CancellationToken cancellationToken) { var report = WechatDoctor.Run(cancellationToken); return Task.FromResult(new { serviceOnline = true, // Process/window presence is availability; a changed control tree is a degraded UI state, // not a stopped WeChat process. wechatAvailable = report.Processes.Count > 0 && report.WindowFound, 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 }); } }