901 lines
49 KiB
C#
901 lines
49 KiB
C#
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<RemoteAgentHostedService> logger,
|
|
RemoteEventQueue? remoteQueue = null,
|
|
IRemoteDataCollector? dataCollector = 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"));
|
|
// A configured Agent connection is the data-sync authorization boundary.
|
|
// EnableDataSync remains only as a read-compatibility property for old service.json files.
|
|
var dataQueue = new RemoteDataBatchQueue(
|
|
Path.Combine(options.DataDirectory, "remote-data-queue.json"), options.DataSyncQueueMaxItems, options.DataSyncQueueMaxBytes);
|
|
var syncState = new RemoteDataSyncStateStore(Path.Combine(options.DataDirectory, "remote-data-sync-state.json"));
|
|
var blockedDataSyncAccounts = new HashSet<string>(StringComparer.Ordinal);
|
|
var lastDataSyncAt = DateTimeOffset.MinValue;
|
|
string? registeredActiveAccountId = null;
|
|
bool? registeredActiveAccountVerified = null;
|
|
long registeredReportingConfigVersion = -1;
|
|
var initialReporting = options.Reporting;
|
|
if (!string.IsNullOrWhiteSpace(options.RemoteConfigurationFile))
|
|
{
|
|
try
|
|
{
|
|
initialReporting = (await LoadRuntimeConfigurationAsync(options, stoppingToken)).Reporting;
|
|
}
|
|
catch (Exception exception) when (exception is IOException or JsonException or WxAgentException)
|
|
{
|
|
logger.LogWarning("Remote reporting configuration could not be loaded for offline collection; type={ExceptionType}.", exception.GetType().Name);
|
|
}
|
|
}
|
|
ReportingConfig? activeReporting = ConnectionAuthorizedReporting(initialReporting, null);
|
|
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 snapshot = await ReadSnapshotAsync(remote, stoppingToken);
|
|
var reporting = ConnectionAuthorizedReporting(runtime.Reporting, snapshot);
|
|
activeReporting = reporting;
|
|
var registrationNeedsRefresh = client.AuthState != RemoteAuthState.Authenticated
|
|
|| !string.Equals(registeredActiveAccountId, snapshot.ActiveAccountId, StringComparison.Ordinal)
|
|
|| registeredActiveAccountVerified != snapshot.ActiveAccountVerified
|
|
|| registeredReportingConfigVersion != reporting.ConfigVersion;
|
|
if (registrationNeedsRefresh)
|
|
{
|
|
await client.RegisterAsync(CreateRegistration(remote, reporting, snapshot), stoppingToken);
|
|
registeredActiveAccountId = snapshot.ActiveAccountId;
|
|
registeredActiveAccountVerified = snapshot.ActiveAccountVerified;
|
|
registeredReportingConfigVersion = reporting.ConfigVersion;
|
|
blockedDataSyncAccounts.Clear();
|
|
retry = RetryDelay;
|
|
}
|
|
|
|
await client.HeartbeatAsync(CreateHeartbeat(remote, reporting, snapshot, null), stoppingToken);
|
|
await ReplayUnreportedResultsAsync(client, ledger, reporting, stoppingToken);
|
|
await client.FlushEventsAsync(eventQueue, reporting, stoppingToken);
|
|
if (dataQueue is not null && syncState is not null && dataCollector is not null
|
|
&& DateTimeOffset.UtcNow - lastDataSyncAt >= TimeSpan.FromSeconds(options.DataSyncIntervalSeconds))
|
|
{
|
|
await ReconcileDataStatusAsync(client, reporting, dataQueue, syncState, blockedDataSyncAccounts, stoppingToken);
|
|
var blockedBeforeFlush = blockedDataSyncAccounts.ToHashSet(StringComparer.Ordinal);
|
|
try
|
|
{
|
|
var confirmedBatches = await client.FlushDataBatchesAsync(dataQueue, reporting, blockedDataSyncAccounts, stoppingToken);
|
|
foreach (var confirmedBatch in confirmedBatches)
|
|
syncState.MarkConfirmed(confirmedBatch);
|
|
foreach (var accountId in blockedDataSyncAccounts.Except(blockedBeforeFlush, StringComparer.Ordinal))
|
|
logger.LogWarning("Data sync authorization was revoked; accountId={AccountId}; pending content was discarded and collection is paused.", accountId);
|
|
}
|
|
catch (Exception exception) when (exception is HttpRequestException or RemoteClientException)
|
|
{
|
|
logger.LogWarning("Data sync delivery deferred; type={ExceptionType}.", exception.GetType().Name);
|
|
}
|
|
// Collection must continue while the platform is unreachable; the durable queue is
|
|
// the offline buffer and will be flushed on the next successful connection. Revoked
|
|
// accounts are the exception: their pending content is discarded and collection stops.
|
|
await CollectDataBatchesAsync(remote, reporting, dataCollector, dataQueue, syncState, blockedDataSyncAccounts, stoppingToken);
|
|
lastDataSyncAt = DateTimeOffset.UtcNow;
|
|
}
|
|
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 TryCollectOfflineDataBatchesAsync(remote, activeReporting, dataCollector, dataQueue, syncState, blockedDataSyncAccounts, stoppingToken);
|
|
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 TryCollectOfflineDataBatchesAsync(remote, activeReporting, dataCollector, dataQueue, syncState, blockedDataSyncAccounts, stoppingToken);
|
|
await DelayAsync(retry, stoppingToken);
|
|
retry = TimeSpan.FromSeconds(Math.Min(retry.TotalSeconds * 2, 30));
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task CollectDataBatchesAsync(
|
|
RemoteAgentOptions remote,
|
|
ReportingConfig reporting,
|
|
IRemoteDataCollector collector,
|
|
RemoteDataBatchQueue queue,
|
|
RemoteDataSyncStateStore state,
|
|
ISet<string> blockedAccountIds,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!remote.IsConfigured)
|
|
return;
|
|
foreach (var account in reporting.Accounts.Where(item => item.Enabled))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (blockedAccountIds.Contains(account.AccountId))
|
|
continue;
|
|
var scopes = AuthorizedChatScopes(reporting, account.AccountId);
|
|
if (scopes.Count == 0 || queue.Pending().Any(batch => string.Equals(batch.AccountId, account.AccountId, StringComparison.Ordinal)
|
|
&& string.Equals(batch.StreamKey, "messages", StringComparison.Ordinal)))
|
|
continue;
|
|
|
|
var checkpoint = state.GetLatest(account.AccountId, "messages");
|
|
var collection = await collector.CollectAsync(account.AccountId, scopes, checkpoint, cancellationToken).ConfigureAwait(false);
|
|
var sourceChanged = !string.Equals(checkpoint.SourceGeneration, collection.SourceGeneration, StringComparison.Ordinal);
|
|
var cursorStart = sourceChanged ? new Dictionary<string, long>(StringComparer.Ordinal) : checkpoint.ConfirmedCursors;
|
|
var cursorChanged = !cursorStart.OrderBy(item => item.Key).SequenceEqual(collection.NextCursors.OrderBy(item => item.Key));
|
|
if (!sourceChanged && !cursorChanged && !collection.HasNewItems && checkpoint.ConfirmedSequence > 0)
|
|
continue;
|
|
|
|
var sequence = sourceChanged ? 1 : checkpoint.ConfirmedSequence + 1;
|
|
var conversationIndex = 0;
|
|
var messageIndex = 0;
|
|
var cursorStartJson = SerializeCursors(cursorStart);
|
|
var cursorEndJson = SerializeCursors(collection.NextCursors);
|
|
var allQueued = true;
|
|
RemoteSyncBatch? lastQueuedBatch = null;
|
|
while (conversationIndex < collection.Conversations.Count || messageIndex < collection.Messages.Count || lastQueuedBatch is null)
|
|
{
|
|
var conversations = new List<RemoteSyncConversation>();
|
|
var messages = new List<RemoteSyncMessage>();
|
|
while (conversationIndex < collection.Conversations.Count && conversations.Count + messages.Count < RemoteDataProtocol.MaxBatchMessages)
|
|
{
|
|
conversations.Add(collection.Conversations[conversationIndex]);
|
|
var candidate = CreateSyncBatch(remote, account.AccountId, collection.SourceGeneration, sequence, cursorStartJson, cursorEndJson, collection.IsComplete, conversations, messages);
|
|
if (JsonSerializer.SerializeToUtf8Bytes(candidate, RemoteJson.Options).LongLength > RemoteDataProtocol.MaxBatchBytes)
|
|
{
|
|
conversations.RemoveAt(conversations.Count - 1);
|
|
break;
|
|
}
|
|
conversationIndex++;
|
|
}
|
|
while (messageIndex < collection.Messages.Count && conversations.Count + messages.Count < RemoteDataProtocol.MaxBatchMessages)
|
|
{
|
|
messages.Add(collection.Messages[messageIndex]);
|
|
var candidate = CreateSyncBatch(remote, account.AccountId, collection.SourceGeneration, sequence, cursorStartJson, cursorEndJson, collection.IsComplete, conversations, messages);
|
|
if (JsonSerializer.SerializeToUtf8Bytes(candidate, RemoteJson.Options).LongLength > RemoteDataProtocol.MaxBatchBytes)
|
|
{
|
|
messages.RemoveAt(messages.Count - 1);
|
|
break;
|
|
}
|
|
messageIndex++;
|
|
}
|
|
if (conversations.Count == 0 && messages.Count == 0)
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "A single sync item exceeds the maximum batch size.");
|
|
var batch = CreateSyncBatch(remote, account.AccountId, collection.SourceGeneration, sequence++, cursorStartJson, cursorEndJson, collection.IsComplete, conversations, messages);
|
|
var queued = queue.Enqueue(reporting, batch);
|
|
if (!queued.Accepted || queued.Batch is null)
|
|
{
|
|
allQueued = false;
|
|
break;
|
|
}
|
|
lastQueuedBatch = queued.Batch;
|
|
cursorStartJson = cursorEndJson;
|
|
}
|
|
if (allQueued && lastQueuedBatch is not null)
|
|
state.MarkCollected(lastQueuedBatch, collection.UnavailableSources);
|
|
if (!collection.IsComplete)
|
|
logger.LogWarning("Data sync is partial; accountId={AccountId}; unavailableSources={UnavailableSourceCount}.", account.AccountId, collection.UnavailableSources.Count);
|
|
}
|
|
}
|
|
|
|
private async Task TryCollectOfflineDataBatchesAsync(
|
|
RemoteAgentOptions remote,
|
|
ReportingConfig? reporting,
|
|
IRemoteDataCollector? collector,
|
|
RemoteDataBatchQueue? queue,
|
|
RemoteDataSyncStateStore? state,
|
|
ISet<string> blockedAccountIds,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (reporting is null || collector is null || queue is null || state is null)
|
|
return;
|
|
try
|
|
{
|
|
await CollectDataBatchesAsync(remote, reporting, collector, queue, state, blockedAccountIds, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch (WxAgentException exception)
|
|
{
|
|
logger.LogWarning("Offline data collection deferred; code={ErrorCode}; message={Message}.", exception.Code, exception.Message);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning("Offline data collection deferred; type={ExceptionType}.", exception.GetType().Name);
|
|
}
|
|
}
|
|
|
|
private async Task ReconcileDataStatusAsync(
|
|
RemoteControlClient client,
|
|
ReportingConfig reporting,
|
|
RemoteDataBatchQueue queue,
|
|
RemoteDataSyncStateStore state,
|
|
ISet<string> blockedAccountIds,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
foreach (var account in reporting.Accounts.Where(item => item.Enabled))
|
|
{
|
|
var local = state.GetLatest(account.AccountId, "messages");
|
|
RemoteDataSyncStatus remote;
|
|
try
|
|
{
|
|
remote = await client.GetDataSyncStatusAsync(account.AccountId, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (RemoteClientException exception) when (exception.StatusCode == 404 && exception.Code == "NotFound")
|
|
{
|
|
// Older control planes do not expose the reconciliation endpoint; retain the legacy ACK path.
|
|
continue;
|
|
}
|
|
catch (RemoteClientException exception) when (exception.StatusCode == 403 && exception.Code == "AccountNotAuthorized")
|
|
{
|
|
var droppedOnRevoke = queue.DropAllForAccount(account.AccountId);
|
|
blockedAccountIds.Add(account.AccountId);
|
|
logger.LogWarning(
|
|
"Data sync authorization is revoked; accountId={AccountId}; pending content was discarded and collection is paused; droppedPendingBatches={DroppedPendingBatches}.",
|
|
account.AccountId, droppedOnRevoke);
|
|
continue;
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
// A disconnected platform must not prevent local DB collection.
|
|
return;
|
|
}
|
|
|
|
if (blockedAccountIds.Remove(account.AccountId))
|
|
logger.LogInformation("Data sync authorization was restored; accountId={AccountId}; collection is resumed.", account.AccountId);
|
|
if (!state.Reconcile(remote, account.AccountId, "messages"))
|
|
continue;
|
|
var dropped = queue.DropForAccount(account.AccountId, "messages");
|
|
logger.LogWarning(
|
|
"Data sync checkpoint reconciled with the platform; accountId={AccountId}; localSequence={LocalSequence}; platformSequence={PlatformSequence}; droppedPendingBatches={DroppedPendingBatches}.",
|
|
account.AccountId, local.ConfirmedSequence, remote.ConfirmedSequence, dropped);
|
|
}
|
|
}
|
|
|
|
private static RemoteSyncBatch CreateSyncBatch(
|
|
RemoteAgentOptions remote,
|
|
string accountId,
|
|
string sourceGeneration,
|
|
long sequence,
|
|
string cursorStart,
|
|
string cursorEnd,
|
|
bool complete,
|
|
IReadOnlyList<RemoteSyncConversation> conversations,
|
|
IReadOnlyList<RemoteSyncMessage> messages)
|
|
{
|
|
var batch = new RemoteSyncBatch(
|
|
remote.NodeId!, accountId, Guid.NewGuid().ToString("N"), sourceGeneration, "messages", sequence,
|
|
cursorStart, cursorEnd, string.Empty, complete ? "complete" : "partial", conversations, messages);
|
|
return batch with { PayloadHash = RemoteDataBatchAuthorization.ComputePayloadHash(batch) };
|
|
}
|
|
|
|
private static string SerializeCursors(IReadOnlyDictionary<string, long> cursors) =>
|
|
JsonSerializer.Serialize(cursors.OrderBy(item => item.Key).ToDictionary(item => item.Key, item => item.Value), RemoteJson.Options);
|
|
|
|
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 == "sync-data")
|
|
{
|
|
var result = new RemoteTaskResult(task.TaskId, task.AccountId, renewed.LeaseGeneration,
|
|
RemoteTaskStatus.Succeeded, null, "后台数据同步已受理;采集器将在下一轮同步周期执行。", false, null, Guid.NewGuid().ToString("N"));
|
|
ledger.Complete(result);
|
|
await ReportResultAsync(client, ledger, result, reporting, cancellationToken);
|
|
return;
|
|
}
|
|
|
|
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<RemoteReadExecution> ExecuteReadTaskAsync(
|
|
RemoteTaskEnvelope task, ReportingConfig reporting, CancellationToken cancellationToken)
|
|
{
|
|
switch (task.Kind)
|
|
{
|
|
case "read-sessions":
|
|
{
|
|
var payload = ReadPayload<ReadSessionsPayload>(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 matchedScopeCount = scopes.Count(scope => sessions.Any(session => SessionMatchesScope(session, scope, contacts)));
|
|
var coverage = CreateCoverage(
|
|
matchedScopeCount == scopes.Count
|
|
? ReadCoverageStates.Complete
|
|
: matchedScopeCount == 0 ? ReadCoverageStates.Unknown : ReadCoverageStates.Partial,
|
|
"uia-session-list",
|
|
sessions.Count,
|
|
scopes.Count,
|
|
matchedScopeCount,
|
|
matchedScopeCount == 0 ? "ScopeIdentityNotObserved" : null);
|
|
var page = visibleSessions.ToPage(payload.Limit, payload.Offset) with { Coverage = coverage };
|
|
return new RemoteReadExecution(JsonSerializer.SerializeToElement(page, RemoteJson.Options), scopes);
|
|
}
|
|
case "read-contacts":
|
|
{
|
|
var payload = ReadPayload<ReadContactsPayload>(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<ContactInfo>();
|
|
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 allChats = scopes.Any(scope => scope.ChatId == "*");
|
|
var allowed = scopes.Select(scope => scope.ChatId).ToHashSet(StringComparer.Ordinal);
|
|
var items = allChats ? all.ToArray() : all.Where(contact => allowed.Contains(contact.Id)).ToArray();
|
|
var matchedScopeCount = scopes.Count(scope => all.Any(contact => string.Equals(contact.Id, scope.ChatId, StringComparison.Ordinal)));
|
|
var coverage = CreateCoverage(
|
|
matchedScopeCount == scopes.Count
|
|
? ReadCoverageStates.Complete
|
|
: matchedScopeCount == 0 ? ReadCoverageStates.Unknown : ReadCoverageStates.Partial,
|
|
"uia-contact-list",
|
|
all.Count,
|
|
scopes.Count,
|
|
matchedScopeCount,
|
|
matchedScopeCount == 0 ? "ScopeIdentityNotObserved" : null);
|
|
var resultPage = items.ToPage(payload.Limit, payload.Offset) with { Coverage = coverage };
|
|
return new RemoteReadExecution(JsonSerializer.SerializeToElement(resultPage, RemoteJson.Options), scopes);
|
|
}
|
|
case "read-messages":
|
|
{
|
|
var payload = ReadPayload<ReadMessagesPayload>(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 coverage = CreateCoverage(ReadCoverageStates.Complete, "uia-session-messages", messages.Count, 1, 1);
|
|
var page = messages.ToPage(payload.Limit, payload.Offset) with { Coverage = coverage };
|
|
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<IReadOnlyList<ContactInfo>> ReadContactsForScopesAsync(
|
|
string accountId, IReadOnlyList<RemoteReportingScope> scopes, CancellationToken cancellationToken)
|
|
{
|
|
var contacts = new Dictionary<string, ContactInfo>(StringComparer.Ordinal);
|
|
if (scopes.Any(scope => scope.ChatId == "*"))
|
|
{
|
|
for (var offset = 0; ;)
|
|
{
|
|
var page = await backend.ContactsAsync(accountId, null, null, 200, offset, cancellationToken);
|
|
foreach (var contact in page.Items)
|
|
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();
|
|
}
|
|
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<ContactInfo> contacts)
|
|
{
|
|
if (scope.ChatId == "*")
|
|
return true;
|
|
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<RemoteReportingScope> 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<RemoteReportingScope> 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<T>(JsonElement payload)
|
|
{
|
|
try
|
|
{
|
|
return payload.Deserialize<T>(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 ReadCoverage CreateCoverage(
|
|
string state,
|
|
string source,
|
|
int observedCount,
|
|
int authorizedScopeCount,
|
|
int matchedScopeCount,
|
|
string? errorCode = null) =>
|
|
new(state, source, observedCount, authorizedScopeCount, matchedScopeCount, DateTimeOffset.UtcNow, errorCode);
|
|
|
|
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<RemoteReportingScope> ChatScopes);
|
|
|
|
private static async Task<RemoteNodeConfiguration> 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<RemoteReportingScope>? chatScopes = null)
|
|
{
|
|
await client.SendTaskResultAsync(result, reporting, chatScopes: chatScopes, cancellationToken: cancellationToken);
|
|
ledger.MarkReported(result.TaskId);
|
|
}
|
|
|
|
private async Task<BackendSnapshot> 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 ReportingConfig ConnectionAuthorizedReporting(ReportingConfig configured, BackendSnapshot? snapshot)
|
|
{
|
|
var accountIds = snapshot is not null
|
|
? snapshot.Accounts.Where(identity => identity.Verified).Select(identity => identity.AccountId)
|
|
: configured.Accounts.Where(account => account.Enabled).Select(account => account.AccountId);
|
|
var accounts = accountIds
|
|
.Distinct(StringComparer.Ordinal)
|
|
.Select(accountId => new AccountReportingConfig
|
|
{
|
|
AccountId = accountId,
|
|
Enabled = true,
|
|
AllowedChats =
|
|
[
|
|
new AllowedChat { Type = ReportingChatType.Group, ChatId = "*", Enabled = true, IdentityVerified = true },
|
|
new AllowedChat { Type = ReportingChatType.Private, ChatId = "*", Enabled = true, IdentityVerified = true }
|
|
]
|
|
})
|
|
.ToArray();
|
|
return configured with
|
|
{
|
|
Enabled = true,
|
|
ConfigVersion = Math.Max(1, configured.ConfigVersion),
|
|
Accounts = accounts
|
|
};
|
|
}
|
|
|
|
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", "db-messages", "db-merged", "report-message"], reporting.ConfigVersion,
|
|
snapshot.Accounts
|
|
.Where(identity => identity.Verified)
|
|
.Select(identity =>
|
|
{
|
|
var account = reporting.FindAccount(identity.AccountId);
|
|
var summary = 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);
|
|
return summary with
|
|
{
|
|
AllowedChats = account?.AllowedChats
|
|
.Where(chat => chat.Enabled && chat.IdentityVerified)
|
|
.Select(chat => new RemoteAllowedChatSummary(chat.ChatId, chat.Type))
|
|
.ToArray() ?? []
|
|
};
|
|
}).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<RemoteAccountIdentity> Accounts);
|
|
}
|