using Microsoft.AspNetCore.Http; using WxAgent.Core; namespace WxAgent.Service; public sealed record AgentCapability(string Operation, bool Implemented, bool Validated, bool Enabled, bool RequiresUi, bool HasSideEffects, string Permission, bool RequiresConfirmation, int TimeoutSeconds, string? DisabledReason, string[] Evidence, string? WechatVersion = null, int ContractVersion = 1); // The only platform seam: Windows is composed by Host; route tests load this assembly unchanged. public interface IAgentBackend { IReadOnlyList Capabilities { get; } Task StatusAsync(CancellationToken cancellationToken); Task DiagnoseAsync(CancellationToken cancellationToken) => StatusAsync(cancellationToken); Task> AccountsAsync(CancellationToken cancellationToken) => Task.FromResult>([]); Task> UiTargetsAsync(CancellationToken cancellationToken) => Task.FromResult>([]); Task BindAccountAsync(string accountId, string targetId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Account binding is not available."); Task UnbindAccountAsync(string accountId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Account binding is not available."); Task> SessionsAsync(CancellationToken cancellationToken) => Task.FromResult>([]); Task> SessionsAsync(string? accountId, CancellationToken cancellationToken) => SessionsAsync(cancellationToken); Task> SearchSessionsAsync(string query, bool exactOnly, CancellationToken cancellationToken) => Task.FromResult>([]); Task> SearchSessionsAsync(string? accountId, string query, bool exactOnly, CancellationToken cancellationToken) => SearchSessionsAsync(query, exactOnly, cancellationToken); Task CurrentSessionAsync(CancellationToken cancellationToken) => Task.FromResult(null); Task CurrentSessionAsync(string? accountId, CancellationToken cancellationToken) => CurrentSessionAsync(cancellationToken); Task OpenSessionAsync(string automationId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Session opening is not available."); Task OpenSessionAsync(string? accountId, string automationId, CancellationToken cancellationToken) => OpenSessionAsync(automationId, cancellationToken); Task ScrollSessionsAsync(string direction, int pages, CancellationToken cancellationToken) => Task.FromResult(new SessionViewportInfo(0, false, [])); Task ScrollSessionsAsync(string? accountId, string direction, int pages, CancellationToken cancellationToken) => ScrollSessionsAsync(direction, pages, cancellationToken); Task> MessagesAsync(string? session, bool includeContent, CancellationToken cancellationToken) => Task.FromResult>([]); Task> MessagesAsync(string? accountId, string? session, bool includeContent, CancellationToken cancellationToken) => MessagesAsync(session, includeContent, cancellationToken); Task SendTextAsync(string accountId, string targetId, string text, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Text sending is not available."); Task> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken cancellationToken) => Task.FromResult(new Page([], limit, offset, false, null)); Task> GroupMembersAsync(string accountId, string group, int limit, int offset, CancellationToken cancellationToken) => Task.FromResult(new Page([], limit, offset, false, null)); Task> DatabaseMessagesAsync(string accountId, string chatId, int limit, long? localId, CancellationToken cancellationToken) => Task.FromResult>([]); Task DatabaseMergedAsync(string accountId, string chatId, long localId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Merged database messages are not available."); } public sealed class AgentService(IAgentBackend backend, ServiceSecurity security, IHttpContextAccessor contexts, OperationQueue operations, ArtifactStore artifacts, AccountBindingStore bindings) { public ServiceIdentity Identity => contexts.HttpContext?.Items[typeof(ServiceIdentity)] as ServiceIdentity ?? throw new ServiceException("Unauthorized", 401, "Authentication required."); internal void RequireEvents() => RequireCapability("listener-events"); private AgentCapability RequireCapability(string operation) { var capability = backend.Capabilities.SingleOrDefault(c => c.Operation == operation) ?? throw new ServiceException("Unsupported", 501, "Capability is not implemented."); security.RequireCurrent(Identity, capability.Permission); if (!capability.Enabled) throw new ServiceException("CapabilityDisabled", 409, capability.DisabledReason ?? "Capability is disabled."); return capability; } public async Task DiagnoseAsync(CancellationToken cancellationToken) { RequireCapability("agent-diagnose"); return await backend.DiagnoseAsync(cancellationToken); } public async Task StatusAsync(CancellationToken cancellationToken) { RequireCapability("agent-status"); var result = await backend.StatusAsync(cancellationToken); security.RequireCurrent(Identity, "read"); return result; } public async Task> AccountsAsync(int limit, int offset, CancellationToken ct) { RequireCapability("accounts-list"); ReadOnlyRequest.Page(limit, offset); var accounts = (await backend.AccountsAsync(ct)).Select(account => { var binding = bindings.Get(account.AccountId); return account with { Binding = binding, IsUiBindingKnown = account.BindingStatus == "Bound" }; }).ToArray(); return accounts.ToPage(limit, offset); } public IReadOnlyList Bindings() { security.RequireCurrent(Identity, "read"); return bindings.ReadAll(); } public async Task> UiTargetsAsync(CancellationToken ct) { security.RequireCurrent(Identity, "read"); return await backend.UiTargetsAsync(ct); } public async Task BindAccountAsync(string accountId, string targetId, CancellationToken ct) { RequireCapability("account-binding"); if (string.IsNullOrWhiteSpace(accountId) || accountId.Length > 200 || string.IsNullOrWhiteSpace(targetId) || targetId.Length > 200) throw new ServiceException("InvalidRequest", 400, "accountId and targetId are required and bounded."); return await backend.BindAccountAsync(accountId, targetId, ct); } public async Task UnbindAccountAsync(string accountId, CancellationToken ct) { RequireCapability("account-binding"); if (string.IsNullOrWhiteSpace(accountId) || accountId.Length > 200) throw new ServiceException("InvalidRequest", 400, "accountId is required and bounded."); await backend.UnbindAccountAsync(accountId, ct); } public async Task> SessionsAsync(string? accountId, int limit, int offset, CancellationToken ct) { RequireCapability("sessions-list"); ReadOnlyRequest.Page(limit, offset); return (await backend.SessionsAsync(accountId, ct)).ToPage(limit, offset); } public Task> SessionsAsync(int limit, int offset, CancellationToken ct) => SessionsAsync(null, limit, offset, ct); public async Task> SearchSessionsAsync(string? accountId, string query, bool exactOnly, int limit, int offset, CancellationToken ct) { RequireCapability("sessions-search"); ReadOnlyRequest.Page(limit, offset); if (string.IsNullOrWhiteSpace(query) || query.Length > 200) throw new ServiceException("InvalidRequest", 400, "query must be 1..200 characters."); return (await backend.SearchSessionsAsync(accountId, query, exactOnly, ct)).ToPage(limit, offset); } public Task> SearchSessionsAsync(string query, bool exactOnly, int limit, int offset, CancellationToken ct) => SearchSessionsAsync(null, query, exactOnly, limit, offset, ct); public async Task CurrentSessionAsync(string? accountId, CancellationToken ct) { RequireCapability("session-current"); return await backend.CurrentSessionAsync(accountId, ct) ?? throw new ServiceException("NotFound", 404, "No current session."); } public Task CurrentSessionAsync(CancellationToken ct) => CurrentSessionAsync(null, ct); public async Task OpenSessionAsync(string? accountId, string automationId, CancellationToken ct) { RequireCapability("session-open"); if (string.IsNullOrWhiteSpace(automationId) || automationId.Length > 512) throw new ServiceException("InvalidRequest", 400, "automationId is required and bounded."); return await backend.OpenSessionAsync(accountId, automationId, ct); } public Task OpenSessionAsync(string automationId, CancellationToken ct) => OpenSessionAsync(null, automationId, ct); public async Task ScrollSessionsAsync(string? accountId, string direction, int pages, CancellationToken ct) { RequireCapability("sessions-scroll"); if (pages is < 1 or > 10 || direction is not ("up" or "down")) throw new ServiceException("InvalidRequest", 400, "direction must be up/down and pages must be 1..10."); return await backend.ScrollSessionsAsync(accountId, direction, pages, ct); } public Task ScrollSessionsAsync(string direction, int pages, CancellationToken ct) => ScrollSessionsAsync(null, direction, pages, ct); public async Task> MessagesAsync(string? accountId, string? session, int limit, int offset, bool includeContent, CancellationToken ct) { RequireCapability("messages-read"); var identity = security.RequireCurrent(Identity, includeContent ? "content" : "read"); ReadOnlyRequest.Page(limit, offset); var values = await backend.MessagesAsync(accountId, session, includeContent && identity.Allows("content"), ct); return values.ToPage(limit, offset); } public Task> MessagesAsync(string? session, int limit, int offset, bool includeContent, CancellationToken ct) => MessagesAsync(null, session, limit, offset, includeContent, ct); public async Task SubmitOperationAsync(OperationSubmitRequest request, CancellationToken ct) { var kind = RequireText(request.Kind, "kind", 80); var accountId = RequireText(request.AccountId, "accountId", 200); var text = PrepareText(request.Text); var idempotencyKey = RequireText(request.IdempotencyKey, "idempotencyKey", 128); if (!request.Confirmed) throw new ServiceException("ConfirmationRequired", 409, "Explicit send confirmation is required."); if (string.Equals(kind, "send-text", StringComparison.Ordinal)) { var targetId = RequireText(request.TargetId, "targetId", 512); var capability = RequireCapability("send-text"); var canonical = System.Text.Json.JsonSerializer.Serialize(new { kind, accountId, targetId, text }); return operations.Submit(Identity, accountId, capability, idempotencyKey, canonical, cancellation => backend.SendTextAsync(accountId, targetId, text, cancellation)); } if (!string.Equals(kind, "broadcast-text", StringComparison.Ordinal)) throw new ServiceException("UnsupportedOperation", 400, "Only send-text and broadcast-text are available in this phase."); if (!string.IsNullOrWhiteSpace(request.TargetId)) throw new ServiceException("InvalidRequest", 400, "broadcast-text uses the targets list, not targetId."); var capabilityForBroadcast = RequireCapability("broadcast-text"); var targets = await FreezeBroadcastTargetsAsync(accountId, request.Targets, ct).ConfigureAwait(false); var initialDetails = System.Text.Json.JsonSerializer.Serialize(new { targetIds = targets, stopOnError = request.StopOnError }, ServiceHost.Json); var canonicalBroadcast = System.Text.Json.JsonSerializer.Serialize(new { kind, accountId, targetIds = targets, text, stopOnError = request.StopOnError }); return operations.SubmitResult(Identity, accountId, capabilityForBroadcast, idempotencyKey, canonicalBroadcast, initialDetails, cancellation => ExecuteBroadcastAsync(accountId, targets, text, request.StopOnError, cancellation)); } private async Task> FreezeBroadcastTargetsAsync(string accountId, IReadOnlyList? requested, CancellationToken ct) { if (requested is null || requested.Count is < 1 or > 20) throw new ServiceException("InvalidRequest", 400, "broadcast-text requires 1..20 targets."); var targets = requested .Select(target => RequireText(target, "target", 512)) .Distinct(StringComparer.Ordinal) .ToArray(); if (targets.Length == 0) throw new ServiceException("InvalidRequest", 400, "broadcast-text requires at least one unique target."); // Freeze the current UI target list before enqueueing. The account binding and identity are // still revalidated by every side-effecting send, so this preflight cannot bypass account safety. var visible = await backend.SessionsAsync(ct).ConfigureAwait(false); var visibleIds = visible.Select(session => session.AutomationId).ToHashSet(StringComparer.Ordinal); var missing = targets.Where(target => !visibleIds.Contains(target)).ToArray(); if (missing.Length > 0) throw new ServiceException("TargetNotFound", 409, "Every broadcast target must be a visible, uniquely bound session."); return targets; } private async Task ExecuteBroadcastAsync(string accountId, IReadOnlyList targets, string text, bool stopOnError, CancellationToken ct) { var items = new List(targets.Count); var stopped = false; string? stopReason = null; string? errorCode = null; var unconfirmed = false; foreach (var target in targets) { try { await backend.SendTextAsync(accountId, target, text, ct).ConfigureAwait(false); items.Add(new BroadcastItemResult(target, "Succeeded", null)); } catch (OperationCanceledException) { items.Add(new BroadcastItemResult(target, "Unconfirmed", "Cancelled")); stopped = true; stopReason = "Cancelled"; errorCode = "Cancelled"; unconfirmed = true; break; } catch (ServiceException exception) { var itemState = exception.Code == "ResultUnconfirmed" ? "Unconfirmed" : "Failed"; items.Add(new BroadcastItemResult(target, itemState, exception.Code)); errorCode ??= exception.Code == "ResultUnconfirmed" ? "ResultUnconfirmed" : "BroadcastItemFailed"; if (itemState == "Unconfirmed" || stopOnError) { stopped = true; stopReason = exception.Code; unconfirmed = itemState == "Unconfirmed"; break; } } catch (WxAgentException exception) { var code = exception.Code.ToString(); var itemState = exception.Code == WxAgentErrorCode.ResultUnconfirmed ? "Unconfirmed" : "Failed"; items.Add(new BroadcastItemResult(target, itemState, code)); errorCode ??= itemState == "Unconfirmed" ? "ResultUnconfirmed" : "BroadcastItemFailed"; if (itemState == "Unconfirmed" || stopOnError) { stopped = true; stopReason = code; unconfirmed = itemState == "Unconfirmed"; break; } } catch (Exception) { items.Add(new BroadcastItemResult(target, "Unconfirmed", "ExecutionFailed")); stopped = true; stopReason = "ExecutionFailed"; errorCode = "ExecutionFailed"; unconfirmed = true; break; } } var details = System.Text.Json.JsonSerializer.Serialize(new BroadcastResult(targets, items, stopped, stopReason), ServiceHost.Json); return new OperationExecutionResult(details, errorCode, unconfirmed); } private static string RequireText(string? value, string name, int maxLength) { if (string.IsNullOrWhiteSpace(value) || value.Length > maxLength) throw new ServiceException("InvalidRequest", 400, $"{name} is required and bounded."); return value; } private static string PrepareText(string? value) { try { return WechatTextInput.Prepare(value ?? string.Empty); } catch (WxAgentException exception) { throw new ServiceException("InvalidRequest", 400, exception.Message); } } public async Task> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken ct) { RequireCapability("contacts-list"); var identity = security.RequireCurrent(Identity, "read"); ReadOnlyRequest.Page(limit, offset); if (!string.IsNullOrWhiteSpace(accountId)) security.RequireCurrent(identity, "read", accountId); return await backend.ContactsAsync(accountId, contains, groupsOnly, limit, offset, ct); } public async Task> GroupMembersAsync(string accountId, string group, int limit, int offset, CancellationToken ct) { RequireCapability("group-members"); var identity = security.RequireCurrent(Identity, "read", accountId); ReadOnlyRequest.Page(limit, offset); return await backend.GroupMembersAsync(accountId, group, limit, offset, ct); } public async Task> DatabaseMessagesAsync(string accountId, string chatId, int limit, int offset, long? localId, CancellationToken ct) { RequireCapability("db-messages"); security.RequireCurrent(Identity, "read", accountId); ReadOnlyRequest.Page(limit, offset); if (string.IsNullOrWhiteSpace(chatId) || chatId.Length > 512) throw new ServiceException("InvalidRequest", 400, "chatId is required and bounded."); return (await backend.DatabaseMessagesAsync(accountId, chatId, Math.Min(500, limit + offset), localId, ct)).ToPage(limit, offset); } public async Task DatabaseMergedAsync(string accountId, string chatId, long localId, CancellationToken ct) { RequireCapability("db-merged"); security.RequireCurrent(Identity, "read", accountId); if (localId <= 0) throw new ServiceException("InvalidRequest", 400, "localId must be positive."); return await backend.DatabaseMergedAsync(accountId, chatId, localId, ct); } public async Task UploadAsync(IFormFile file, CancellationToken ct) { security.RequireCurrent(Identity, "write"); return await artifacts.SaveAsync(Identity.PrincipalId, file, ct); } public FileStream Download(string id) { security.RequireCurrent(Identity, "content"); return artifacts.Open(Identity.PrincipalId, id); } public Page Operations(string? accountId, int limit, int offset) { ReadOnlyRequest.Page(limit, offset); if (accountId is { Length: > 200 }) throw new ServiceException("InvalidRequest", 400, "accountId is too long."); return operations.List(Identity, accountId, limit, offset); } public OperationRecord Operation(string id) => operations.Get(Identity, id); public OperationRecord CancelOperation(string id) => operations.Cancel(Identity, id); public IReadOnlyList Capabilities() { var identity = security.RequireCurrent(Identity, "read"); return backend.Capabilities.Select(c => identity.Allows(c.Permission) ? c : c with { Enabled = false, DisabledReason = "Permission required." }).ToArray(); } }