using WxAgent.Core; using WxAgent.Service; using WxAgent.Windows; namespace WxAgent.Host; internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBackend, IAgentEventSource { public IReadOnlyList Capabilities { get; } = [ 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("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"), new("sessions-scroll", true, false, false, true, false, "manage", false, 30, "Requires manage permission and navigation acceptance.", ["Navigation acceptance pending."], "4.1.13.63"), new("session-open", true, false, false, true, true, "manage", false, 30, "Requires manage permission and navigation acceptance.", ["Navigation acceptance pending."], "4.1.13.63"), new("messages-read", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), new("contacts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), 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.63"), 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.63"), new("group-members", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), new("listener-events", true, options.EnableListenerEvents, options.EnableListenerEvents, true, false, "read", false, 30, options.EnableListenerEvents ? null : "Listener events require explicit opt-in and recovery validation.", ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), new("send-text", true, false, false, true, true, "write", false, 30, "Active UI/database account binding has not been verified for this service session.", []), new("group-at-all", true, false, false, true, true, "write", true, 30, "Service account binding and group-owner validation required.", []), 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) { var checkpoint = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WxAgent", "listener-checkpoint.json"); while (!cancellationToken.IsCancellationRequested) { await foreach (var item in WechatChatClient.ListenEventsAsync(TimeSpan.FromMinutes(5), checkpoint, cancellationToken, session: options.ListenerSession)) { yield return new AgentEvent(item.EventId, "ui-current", item.Session, item.Kind.ToString(), item.Message is null ? "listener state" : item.Message.Type.ToString(), item.ObservedAt); } } } public Task> AccountsAsync(CancellationToken cancellationToken) { var roots = WechatDatabaseDiscovery.FindAccountRoots(cancellationToken: cancellationToken); // Database fingerprints are deliberately not merged with the current UI identity. return Task.FromResult>(roots.Select(root => new AccountInfo(root.Fingerprint, null, null, null, root.Fingerprint, false)).ToArray()); } 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 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 async Task CurrentSessionAsync(CancellationToken cancellationToken) { var current = await WechatChatClient.GetCurrentSessionAsync(cancellationToken); return current is null ? null : new SessionInfo(current.Name, current.AutomationId, true); } 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 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 async Task> 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 async Task> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken cancellationToken) { 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 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, wechatAvailable = report.Errors.Count == 0, sessionAvailable = report.UserInteractive && report.InputDesktopAvailable, report.WindowFound, errors = report.Errors.Select(e => e.ToString()), wechatVersions = report.Processes.Select(p => p.Version).Where(v => v is not null).Distinct(), activeAccountBound = false, defaultReadOnly = true }); } }