using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using WxAgent.Core; namespace WxAgent.Service; public sealed class RemoteAgentHostedService( ServiceOptions options, IAgentBackend backend, ILogger logger, RemoteEventQueue? remoteQueue = null) : BackgroundService { private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(10); private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2); private readonly RemoteAccountContext accountContext = new(); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { RemoteAgentOptions? configuredRemote = options.Remote; if (!string.IsNullOrWhiteSpace(options.RemoteConfigurationFile)) { try { configuredRemote = (await RemoteNodeConfigurationStore.LoadAsync(options.RemoteConfigurationFile, stoppingToken)).Remote; } catch (Exception exception) when (exception is IOException or JsonException or WxAgentException) { logger.LogWarning("Remote agent is disabled because the external configuration could not be loaded; type={ExceptionType}.", exception.GetType().Name); return; } } if (configuredRemote is null || !configuredRemote.IsConfigured) { logger.LogInformation("Remote agent is disabled because authAddress, token or nodeId is not configured."); return; } var remote = configuredRemote; using var client = new RemoteControlClient(remote); var ledger = new RemoteTaskLedger(Path.Combine(options.DataDirectory, "remote-task-ledger.json")); var eventQueue = remoteQueue ?? new RemoteEventQueue(Path.Combine(options.DataDirectory, "remote-event-queue.json")); var retry = RetryDelay; while (!stoppingToken.IsCancellationRequested) { try { var runtime = await LoadRuntimeConfigurationAsync(options, stoppingToken); if (!runtime.Remote.IsConfigured || !runtime.Remote.Equals(remote)) { logger.LogWarning("Remote configuration changed or is not valid; remote work is paused until the service is restarted."); await DelayAsync(RetryDelay, stoppingToken); continue; } var reporting = runtime.Reporting; var snapshot = await ReadSnapshotAsync(remote, stoppingToken); if (client.AuthState != RemoteAuthState.Authenticated) { await client.RegisterAsync(CreateRegistration(remote, reporting, snapshot), stoppingToken); retry = RetryDelay; } await client.HeartbeatAsync(CreateHeartbeat(remote, reporting, snapshot, null), stoppingToken); await ReplayUnreportedResultsAsync(client, ledger, reporting, stoppingToken); await client.FlushEventsAsync(eventQueue, reporting, stoppingToken); var pollAccountIds = reporting.Accounts .Where(account => account.Enabled) .Select(account => account.AccountId) .ToList(); if (snapshot.ActiveAccountId is { Length: > 0 } activeAccountId && !pollAccountIds.Contains(activeAccountId, StringComparer.Ordinal)) { // Poll the verified active account even before a Reporting whitelist is configured. // The task then returns an explicit authorization result instead of staying Pending. pollAccountIds.Add(activeAccountId); } foreach (var accountId in pollAccountIds) { var tasks = await client.PollTasksAsync(accountId, stoppingToken, waitSeconds: 5); foreach (var task in tasks) { await ProcessTaskAsync(client, remote, reporting, ledger, task, stoppingToken); } } await Task.Delay(HeartbeatInterval, stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (RemoteClientException exception) { logger.LogWarning("Remote control-plane request failed; code={Code}; status={StatusCode}; correlationId={CorrelationId}.", exception.Code, exception.StatusCode, exception.CorrelationId); await DelayAsync(retry, stoppingToken); retry = TimeSpan.FromSeconds(Math.Min(retry.TotalSeconds * 2, 30)); } catch (Exception exception) { logger.LogWarning("Remote agent cycle failed; type={ExceptionType}.", exception.GetType().Name); await DelayAsync(retry, stoppingToken); retry = TimeSpan.FromSeconds(Math.Min(retry.TotalSeconds * 2, 30)); } } } private async Task ProcessTaskAsync( RemoteControlClient client, RemoteAgentOptions remote, ReportingConfig reporting, RemoteTaskLedger ledger, RemoteTaskEnvelope task, CancellationToken cancellationToken) { if (task.Status is RemoteTaskStatus.Accepted or RemoteTaskStatus.Running) { if (ledger.TryGet(task.TaskId, out var existing) && existing?.Result is not null) { await ReportResultAsync(client, ledger, existing.Result, reporting, cancellationToken); } else { var result = new RemoteTaskResult(task.TaskId, task.AccountId, task.LeaseGeneration, RemoteTaskStatus.ResultUnconfirmed, "AgentRestartedWithIncompleteTask", "Execution was not replayed.", true, null, Guid.NewGuid().ToString("N")); ledger.Complete(result); await ReportResultAsync(client, ledger, result, reporting, cancellationToken); } return; } if (task.Status != RemoteTaskStatus.Pending) return; if (!ledger.Accept(task)) { if (ledger.TryGet(task.TaskId, out var existing) && existing?.Result is not null) await ReportResultAsync(client, ledger, existing.Result, reporting, cancellationToken); return; } var accepted = await client.AcknowledgeTaskAsync(task, cancellationToken); ledger.MarkAccepted(accepted); if (accepted.Status == RemoteTaskStatus.Cancelled || accepted.CancelRequestedAt is not null) { var result = new RemoteTaskResult(accepted.TaskId, accepted.AccountId, accepted.LeaseGeneration, RemoteTaskStatus.Cancelled, "CancelRequestedBeforeExecution", "Execution was not started.", false, null, Guid.NewGuid().ToString("N")); ledger.Complete(result); if (accepted.Status != RemoteTaskStatus.Cancelled) await ReportResultAsync(client, ledger, result, reporting, cancellationToken); else ledger.MarkReported(result.TaskId); return; } var currentSnapshot = await ReadSnapshotAsync(remote, cancellationToken); if (!string.Equals(currentSnapshot.ActiveAccountId, task.AccountId, StringComparison.Ordinal) || !currentSnapshot.ActiveAccountVerified) { var result = new RemoteTaskResult(task.TaskId, task.AccountId, task.LeaseGeneration, RemoteTaskStatus.Failed, "AccountContextUnconfirmed", "The active account binding could not be confirmed.", false, null, Guid.NewGuid().ToString("N")); ledger.Complete(result); await ReportResultAsync(client, ledger, result, reporting, cancellationToken); return; } // Persist the local running barrier before asking the center to mark the lease Running. ledger.MarkRunning(accepted); var started = await client.StartTaskAsync(accepted, cancellationToken); if (started.Status != RemoteTaskStatus.Running) { var terminalStatus = started.Status is RemoteTaskStatus.Cancelled or RemoteTaskStatus.Expired ? started.Status : RemoteTaskStatus.ResultUnconfirmed; var result = new RemoteTaskResult(task.TaskId, task.AccountId, task.LeaseGeneration, terminalStatus, "ExecutionStartUnconfirmed", "Execution was not started after lease validation.", false, null, Guid.NewGuid().ToString("N")); ledger.Complete(result); ledger.MarkReported(result.TaskId); return; } var renewed = await client.RenewTaskAsync(started, cancellationToken); if (task.Kind == "send-text") { if (!TryReadSendTextPayload(task.Payload, out var targetId, out var text)) { var result = new RemoteTaskResult(task.TaskId, task.AccountId, renewed.LeaseGeneration, RemoteTaskStatus.Failed, "InvalidTaskPayload", "The task payload did not pass the node command allowlist.", false, null, Guid.NewGuid().ToString("N")); ledger.Complete(result); await ReportResultAsync(client, ledger, result, reporting, cancellationToken); return; } try { await backend.SendTextAsync(task.AccountId, targetId, text, cancellationToken); var result = new RemoteTaskResult(task.TaskId, task.AccountId, renewed.LeaseGeneration, RemoteTaskStatus.Succeeded, null, null, true, null, Guid.NewGuid().ToString("N")); ledger.Complete(result); await ReportResultAsync(client, ledger, result, reporting, cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (ServiceException exception) { var result = new RemoteTaskResult(task.TaskId, task.AccountId, renewed.LeaseGeneration, RemoteTaskStatus.Failed, exception.Code, "The node operation failed.", true, null, Guid.NewGuid().ToString("N")); ledger.Complete(result); await ReportResultAsync(client, ledger, result, reporting, cancellationToken); } catch (WxAgentException exception) { var result = new RemoteTaskResult(task.TaskId, task.AccountId, renewed.LeaseGeneration, RemoteTaskStatus.Failed, exception.Code.ToString(), "The node operation failed.", true, null, Guid.NewGuid().ToString("N")); ledger.Complete(result); await ReportResultAsync(client, ledger, result, reporting, cancellationToken); } return; } if (!IsReadTaskKind(task.Kind)) { var result = new RemoteTaskResult(task.TaskId, task.AccountId, renewed.LeaseGeneration, RemoteTaskStatus.Failed, "UnsupportedTask", "The task kind is not enabled on this node.", false, null, Guid.NewGuid().ToString("N")); ledger.Complete(result); await ReportResultAsync(client, ledger, result, reporting, cancellationToken); return; } try { var read = await ExecuteReadTaskAsync(task, reporting, cancellationToken); var result = new RemoteTaskResult(task.TaskId, task.AccountId, renewed.LeaseGeneration, RemoteTaskStatus.Succeeded, null, null, false, read.Content, Guid.NewGuid().ToString("N")); ledger.Complete(result, read.ChatScopes); await ReportResultAsync(client, ledger, result, reporting, cancellationToken, chatScopes: read.ChatScopes); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (ServiceException exception) { var result = new RemoteTaskResult(task.TaskId, task.AccountId, renewed.LeaseGeneration, RemoteTaskStatus.Failed, exception.Code, "The node read operation failed.", false, null, Guid.NewGuid().ToString("N")); ledger.Complete(result); await ReportResultAsync(client, ledger, result, reporting, cancellationToken); } catch (WxAgentException exception) { var result = new RemoteTaskResult(task.TaskId, task.AccountId, renewed.LeaseGeneration, RemoteTaskStatus.Failed, exception.Code.ToString(), "The node read operation failed.", false, null, Guid.NewGuid().ToString("N")); ledger.Complete(result); await ReportResultAsync(client, ledger, result, reporting, cancellationToken); } } private async Task ExecuteReadTaskAsync( RemoteTaskEnvelope task, ReportingConfig reporting, CancellationToken cancellationToken) { switch (task.Kind) { case "read-sessions": { var payload = ReadPayload(task.Payload); ValidatePage(payload.Limit, payload.Offset); var scopes = AuthorizedChatScopes(reporting, task.AccountId); RequireScopes(reporting, task.AccountId, scopes); var sessions = await backend.SessionsAsync(task.AccountId, cancellationToken); var contacts = await ReadContactsForScopesAsync(task.AccountId, scopes, cancellationToken); var visibleSessions = sessions .Where(session => scopes.Any(scope => SessionMatchesScope(session, scope, contacts))) .ToArray(); var page = visibleSessions.ToPage(payload.Limit, payload.Offset); return new RemoteReadExecution(JsonSerializer.SerializeToElement(page, RemoteJson.Options), scopes); } case "read-contacts": { var payload = ReadPayload(task.Payload); ValidatePage(payload.Limit, payload.Offset); var chatType = payload.GroupsOnly ? ReportingChatType.Group : ReportingChatType.Private; var scopes = AuthorizedChatScopes(reporting, task.AccountId, chatType); RequireScopes(reporting, task.AccountId, scopes); var all = new List(); for (var offset = 0; ;) { var page = await backend.ContactsAsync(task.AccountId, payload.Contains, payload.GroupsOnly, 200, offset, cancellationToken); all.AddRange(page.Items); if (!page.HasMore) break; var next = page.NextOffset ?? offset + page.Items.Count; if (next <= offset) throw new ServiceException("InvalidPage", 500, "The node returned a non-advancing contact page."); offset = next; } var allowed = scopes.Select(scope => scope.ChatId).ToHashSet(StringComparer.Ordinal); var items = all.Where(contact => allowed.Contains(contact.Id)).ToArray(); var resultPage = items.ToPage(payload.Limit, payload.Offset); return new RemoteReadExecution(JsonSerializer.SerializeToElement(resultPage, RemoteJson.Options), scopes); } case "read-messages": { var payload = ReadPayload(task.Payload); ValidatePage(payload.Limit, payload.Offset); var scopes = AuthorizedChatScopes(reporting, task.AccountId); RequireScopes(reporting, task.AccountId, scopes); var sessions = await backend.SessionsAsync(task.AccountId, cancellationToken); var contacts = await ReadContactsForScopesAsync(task.AccountId, scopes, cancellationToken); var candidateSessions = sessions .Where(session => string.Equals(session.AutomationId, payload.ChatId, StringComparison.Ordinal)) .ToArray(); var scope = scopes.FirstOrDefault(allowed => string.Equals(allowed.ChatId, payload.ChatId, StringComparison.Ordinal)); if (scope is null) { var mapped = sessions .Where(session => scopes.Any(allowed => SessionMatchesScope(session, allowed, contacts))) .Where(session => string.Equals(session.AutomationId, payload.ChatId, StringComparison.Ordinal)) .ToArray(); if (mapped.Length == 1) { candidateSessions = mapped; scope = scopes.First(allowed => SessionMatchesScope(mapped[0], allowed, contacts)); } } if (scope is null) throw new ServiceException("ChatNotAuthorized", 403, "The requested chat is not enabled in the local whitelist."); if (candidateSessions.Length != 1) throw new ServiceException("ChatIdentityUnconfirmed", 409, "The requested chat identity is not uniquely visible."); var messages = await backend.MessagesAsync(task.AccountId, candidateSessions[0].AutomationId, payload.IncludeContent, cancellationToken); var page = messages.ToPage(payload.Limit, payload.Offset); return new RemoteReadExecution(JsonSerializer.SerializeToElement(page, RemoteJson.Options), scopes); } default: throw new ServiceException("UnsupportedTask", 400, "The task kind is not a read operation."); } } private async Task> ReadContactsForScopesAsync( string accountId, IReadOnlyList scopes, CancellationToken cancellationToken) { var contacts = new Dictionary(StringComparer.Ordinal); foreach (var scope in scopes) { for (var offset = 0; ;) { var page = await backend.ContactsAsync( accountId, contains: scope.ChatId, groupsOnly: scope.ChatType == ReportingChatType.Group, 200, offset, cancellationToken); foreach (var contact in page.Items.Where(contact => string.Equals(contact.Id, scope.ChatId, StringComparison.Ordinal))) contacts[contact.Id] = contact; if (!page.HasMore) break; var next = page.NextOffset ?? offset + page.Items.Count; if (next <= offset) throw new ServiceException("InvalidPage", 500, "The node returned a non-advancing contact page."); offset = next; } } return contacts.Values.ToArray(); } private static bool SessionMatchesScope(SessionInfo session, RemoteReportingScope scope, IReadOnlyList contacts) { if (string.Equals(scope.ChatId, session.AutomationId, StringComparison.Ordinal) || string.Equals(scope.ChatId, session.Name, StringComparison.Ordinal)) return true; var matches = contacts.Where(contact => string.Equals(contact.Id, scope.ChatId, StringComparison.Ordinal)).ToArray(); return matches.Length == 1 && matches[0] is { } contact && (string.Equals(contact.DisplayName, session.Name, StringComparison.Ordinal) || string.Equals(contact.Remark, session.Name, StringComparison.Ordinal)); } private static IReadOnlyList AuthorizedChatScopes( ReportingConfig reporting, string accountId, ReportingChatType? chatType = null) => (reporting.FindAccount(accountId)?.AllowedChats ?? []) .Where(chat => chat.Enabled && chat.IdentityVerified && (chatType is null || chat.Type == chatType)) .Select(chat => new RemoteReportingScope(chat.ChatId, chat.Type)) .Where(scope => ReportingAuthorization.IsAllowed(reporting, accountId, scope.ChatId, scope.ChatType, ReportingDataType.TaskResult)) .Distinct() .ToArray(); private static void RequireScopes(ReportingConfig reporting, string accountId, IReadOnlyList scopes) { if (!reporting.Enabled) throw new ServiceException("ReportingDisabled", 403, "Reporting is disabled on the node."); var account = reporting.FindAccount(accountId); if (account is null || !account.Enabled) throw new ServiceException("AccountNotAuthorized", 403, "The target account is not enabled in the local Reporting configuration."); if (scopes.Count == 0) throw new ServiceException("ChatNotAuthorized", 403, "The requested data scope is not enabled in the local whitelist."); } private static T ReadPayload(JsonElement payload) { try { return payload.Deserialize(RemoteJson.Options) ?? throw new ServiceException("InvalidTaskPayload", 400, "The read task payload is empty."); } catch (JsonException) { throw new ServiceException("InvalidTaskPayload", 400, "The read task payload is invalid."); } } private static void ValidatePage(int limit, int offset) { if (limit is < 1 or > 200 || offset < 0) throw new ServiceException("InvalidPagination", 400, "limit must be 1..200 and offset must be non-negative."); } private static bool IsReadTaskKind(string kind) => kind is "read-sessions" or "read-contacts" or "read-messages"; private sealed record ReadSessionsPayload( [property: JsonPropertyName("limit")] int Limit, [property: JsonPropertyName("offset")] int Offset); private sealed record ReadContactsPayload( [property: JsonPropertyName("limit")] int Limit, [property: JsonPropertyName("offset")] int Offset, [property: JsonPropertyName("contains")] string? Contains, [property: JsonPropertyName("groups_only")] bool GroupsOnly); private sealed record ReadMessagesPayload( [property: JsonPropertyName("limit")] int Limit, [property: JsonPropertyName("offset")] int Offset, [property: JsonPropertyName("chat_id")] string ChatId, [property: JsonPropertyName("include_content")] bool IncludeContent); private sealed record RemoteReadExecution( JsonElement Content, IReadOnlyList ChatScopes); private static async Task LoadRuntimeConfigurationAsync(ServiceOptions options, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(options.RemoteConfigurationFile)) return new RemoteNodeConfiguration { Remote = options.Remote ?? new RemoteAgentOptions(), Reporting = options.Reporting }; return await RemoteNodeConfigurationStore.LoadAsync(options.RemoteConfigurationFile, cancellationToken); } private static async Task ReplayUnreportedResultsAsync( RemoteControlClient client, RemoteTaskLedger ledger, ReportingConfig reporting, CancellationToken cancellationToken) { foreach (var pending in ledger.UnreportedResultsWithScopes()) await ReportResultAsync(client, ledger, pending.Result, reporting, cancellationToken, pending.ReportingScopes); } private static async Task ReportResultAsync( RemoteControlClient client, RemoteTaskLedger ledger, RemoteTaskResult result, ReportingConfig reporting, CancellationToken cancellationToken, IReadOnlyList? chatScopes = null) { await client.SendTaskResultAsync(result, reporting, chatScopes: chatScopes, cancellationToken: cancellationToken); ledger.MarkReported(result.TaskId); } private async Task ReadSnapshotAsync(RemoteAgentOptions remote, CancellationToken cancellationToken) { try { var status = await backend.StatusAsync(cancellationToken); var element = JsonSerializer.SerializeToElement(status, RemoteJson.Options); var wechatRunning = GetBoolean(element, "wechatAvailable"); var sessionAvailable = GetBoolean(element, "sessionAvailable"); var sessionLocked = GetBoolean(element, "sessionLocked"); // Heartbeats must not refresh UI identity. Binding already contains the verified identity; // the explicit accounts API remains the only path that may inspect the profile. var accounts = await backend.AccountsAsync(cancellationToken, refreshUiIdentity: false); var identities = accounts.Select(account => new RemoteAccountIdentity( account.AccountId, account.IsUiBindingKnown && account.Binding is not null && string.Equals(account.BindingStatus, "Bound", StringComparison.Ordinal))).ToArray(); var activeAccountId = remote.ActiveAccountId; var boundAccounts = accounts .Where(account => account.IsUiBindingKnown && account.Binding is not null && string.Equals(account.BindingStatus, "Bound", StringComparison.Ordinal)) .Select(account => account.AccountId) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); if ((string.IsNullOrWhiteSpace(activeAccountId) || !identities.Any(identity => identity.Verified && string.Equals(identity.AccountId, activeAccountId, StringComparison.Ordinal))) && boundAccounts.Length == 1) { // A single verified binding is safe to use when the optional GUI value is empty or a display name. activeAccountId = boundAccounts[0]; } var activeAccountVerified = false; if (activeAccountId is { Length: > 0 }) { try { accountContext.SwitchTo(activeAccountId, identities); activeAccountVerified = accountContext.IsConfirmedFor(activeAccountId); } catch (WxAgentException) { accountContext.Invalidate(); } } else { accountContext.Invalidate(); } var nodeStatus = sessionLocked ? RemoteNodeStatus.SessionLocked : !wechatRunning ? RemoteNodeStatus.WechatNotRunning : !sessionAvailable ? RemoteNodeStatus.WechatNotLoggedIn : !activeAccountVerified ? RemoteNodeStatus.Degraded : RemoteNodeStatus.Online; return new BackendSnapshot(nodeStatus, wechatRunning, sessionAvailable, sessionLocked, activeAccountId, 0, activeAccountVerified, identities); } catch { return new BackendSnapshot(RemoteNodeStatus.Degraded, false, false, false, remote.ActiveAccountId, 0, false, []); } } private static RemoteNodeRegistration CreateRegistration(RemoteAgentOptions remote, ReportingConfig reporting, BackendSnapshot snapshot) => new(remote.NodeId!, typeof(RemoteAgentHostedService).Assembly.GetName().Version?.ToString() ?? "dev", RemoteProtocol.Version, ["heartbeat", "poll-tasks", "send-text", "read-sessions", "read-contacts", "read-messages", "report-message"], reporting.ConfigVersion, snapshot.Accounts .Where(identity => identity.Verified) .Select(identity => { var account = reporting.FindAccount(identity.AccountId); return new RemoteAccountSummary( identity.AccountId, string.Equals(identity.AccountId, snapshot.ActiveAccountId, StringComparison.Ordinal), identity.Verified, account?.AllowedChats.Count(chat => chat.Type == ReportingChatType.Group && chat.Enabled && chat.IdentityVerified) ?? 0, account?.AllowedChats.Count(chat => chat.Type == ReportingChatType.Private && chat.Enabled && chat.IdentityVerified) ?? 0); }).ToArray()); private static RemoteHeartbeat CreateHeartbeat(RemoteAgentOptions remote, ReportingConfig reporting, BackendSnapshot snapshot, string? errorCode) => new(remote.NodeId!, typeof(RemoteAgentHostedService).Assembly.GetName().Version?.ToString() ?? "dev", RemoteProtocol.Version, snapshot.Status, snapshot.WechatRunning, snapshot.WechatLoggedIn, snapshot.SessionLocked, snapshot.ActiveAccountVerified ? snapshot.ActiveAccountId : null, snapshot.QueueLength, reporting.ConfigVersion, Guid.NewGuid().ToString("N"), errorCode); private static bool TryReadSendTextPayload(JsonElement payload, out string targetId, out string text) { targetId = ""; text = ""; if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty("target_id", out var target) || !payload.TryGetProperty("text", out var content) || !payload.TryGetProperty("confirmed", out var confirmed) || target.ValueKind != JsonValueKind.String || content.ValueKind != JsonValueKind.String || confirmed.ValueKind != JsonValueKind.True) return false; targetId = target.GetString() ?? ""; text = content.GetString() ?? ""; return targetId.Length is > 0 and <= 512 && text.Length is > 0 and <= 4000; } private static bool GetBoolean(JsonElement value, string propertyName) => value.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.True; private static async Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) => await Task.Delay(delay, cancellationToken); private sealed record BackendSnapshot( RemoteNodeStatus Status, bool WechatRunning, bool WechatLoggedIn, bool SessionLocked, string? ActiveAccountId, int QueueLength, bool ActiveAccountVerified, IReadOnlyList Accounts); }