feat: add remote control plane and whitelist reads
Build web service image / build (push) Successful in 1m53s
Build web service image / build (push) Successful in 1m53s
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public sealed record RemoteAccountIdentity(string AccountId, bool Verified);
|
||||
|
||||
public sealed record RemoteAccountContextSnapshot(
|
||||
string? ActiveAccountId,
|
||||
long Revision,
|
||||
bool Confirmed);
|
||||
|
||||
public sealed class RemoteAccountContext
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private RemoteAccountContextSnapshot _snapshot = new(null, 0, false);
|
||||
|
||||
public RemoteAccountContextSnapshot Snapshot
|
||||
{
|
||||
get { lock (_gate) return _snapshot; }
|
||||
}
|
||||
|
||||
public RemoteAccountContextSnapshot SwitchTo(
|
||||
string accountId,
|
||||
IReadOnlyCollection<RemoteAccountIdentity> identities,
|
||||
bool hasInFlightWrites = false)
|
||||
{
|
||||
RemoteAgentOptions.ValidateIdentifier(accountId, "accountId", 200);
|
||||
ArgumentNullException.ThrowIfNull(identities);
|
||||
lock (_gate)
|
||||
{
|
||||
if (hasInFlightWrites && !string.Equals(_snapshot.ActiveAccountId, accountId, StringComparison.Ordinal))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Account switching is blocked while a write operation is in flight.");
|
||||
var matches = identities.Where(identity => string.Equals(identity.AccountId, accountId, StringComparison.Ordinal)).ToArray();
|
||||
if (matches.Length != 1 || !matches[0].Verified)
|
||||
{
|
||||
_snapshot = new RemoteAccountContextSnapshot(null, checked(_snapshot.Revision + 1), false);
|
||||
throw new WxAgentException(WxAgentErrorCode.AccountContextUnconfirmed, "The requested account identity could not be confirmed.");
|
||||
}
|
||||
if (_snapshot.Confirmed && string.Equals(_snapshot.ActiveAccountId, accountId, StringComparison.Ordinal))
|
||||
return _snapshot;
|
||||
_snapshot = new RemoteAccountContextSnapshot(accountId, checked(_snapshot.Revision + 1), true);
|
||||
return _snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
public void Invalidate()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_snapshot.Confirmed && _snapshot.ActiveAccountId is null) return;
|
||||
_snapshot = new RemoteAccountContextSnapshot(null, checked(_snapshot.Revision + 1), false);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsConfirmedFor(string accountId)
|
||||
{
|
||||
lock (_gate)
|
||||
return _snapshot.Confirmed && string.Equals(_snapshot.ActiveAccountId, accountId, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public static class RemoteProtocol
|
||||
{
|
||||
public const string Version = "v1";
|
||||
public const int MaxTaskPayloadBytes = 64 * 1024;
|
||||
public const int MaxTaskResultBytes = 512 * 1024;
|
||||
public const int MaxEventContentLength = 16 * 1024;
|
||||
}
|
||||
|
||||
public enum RemoteAuthState
|
||||
{
|
||||
NotConfigured,
|
||||
Authenticating,
|
||||
Authenticated,
|
||||
AuthenticationFailed
|
||||
}
|
||||
|
||||
public enum RemoteNodeStatus
|
||||
{
|
||||
Registered,
|
||||
Online,
|
||||
Degraded,
|
||||
Offline,
|
||||
SessionLocked,
|
||||
WechatNotRunning,
|
||||
WechatNotLoggedIn
|
||||
}
|
||||
|
||||
public enum RemoteTaskStatus
|
||||
{
|
||||
Pending,
|
||||
Accepted,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Cancelled,
|
||||
Expired,
|
||||
ResultUnconfirmed
|
||||
}
|
||||
|
||||
public enum ReportingChatType
|
||||
{
|
||||
Group,
|
||||
Private
|
||||
}
|
||||
|
||||
public enum ReportingDataType
|
||||
{
|
||||
Message,
|
||||
TaskResult,
|
||||
Error,
|
||||
Diagnostic
|
||||
}
|
||||
|
||||
public sealed record RemoteAgentOptions
|
||||
{
|
||||
[JsonPropertyName("authAddress")]
|
||||
public string? AuthAddress { get; init; }
|
||||
|
||||
[JsonPropertyName("token")]
|
||||
public string? Token { get; init; }
|
||||
|
||||
[JsonPropertyName("nodeId")]
|
||||
public string? NodeId { get; init; }
|
||||
|
||||
[JsonPropertyName("activeAccountId")]
|
||||
public string? ActiveAccountId { get; init; }
|
||||
|
||||
[JsonPropertyName("allowInsecureHttp")]
|
||||
public bool AllowInsecureHttp { get; init; }
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(AuthAddress)
|
||||
&& !string.IsNullOrWhiteSpace(Token)
|
||||
&& !string.IsNullOrWhiteSpace(NodeId);
|
||||
|
||||
public string TokenState => string.IsNullOrWhiteSpace(Token) ? "not-configured" : "configured";
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(AuthAddress) || string.IsNullOrWhiteSpace(Token) || string.IsNullOrWhiteSpace(NodeId))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "authAddress, token and nodeId are required before remote access is enabled.");
|
||||
if (!Uri.TryCreate(AuthAddress, UriKind.Absolute, out var uri) || uri is null
|
||||
|| uri.AbsolutePath == "/" && uri.Query.Length != 0
|
||||
|| uri.UserInfo.Length != 0
|
||||
|| uri.Scheme is not ("https" or "http"))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "authAddress must be an HTTPS URL or an HTTP URL for an explicitly trusted endpoint.");
|
||||
if (uri.Scheme == "http" && !IsLoopback(uri.Host)
|
||||
&& (!AllowInsecureHttp || !IsPrivateNetwork(uri.Host)))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Non-loopback HTTP requires allowInsecureHttp=true and a private-network IP address.");
|
||||
ValidateIdentifier(NodeId, "nodeId", 200);
|
||||
if (Token.Any(char.IsWhiteSpace))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "token must not contain whitespace.");
|
||||
}
|
||||
|
||||
public RemoteAgentOptions Redacted() => this with { Token = string.IsNullOrWhiteSpace(Token) ? null : "<redacted>" };
|
||||
|
||||
private static bool IsLoopback(string host) => host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|
||||
|| System.Net.IPAddress.TryParse(host.Trim('[', ']'), out var address) && System.Net.IPAddress.IsLoopback(address);
|
||||
|
||||
private static bool IsPrivateNetwork(string host)
|
||||
{
|
||||
if (!System.Net.IPAddress.TryParse(host.Trim('[', ']'), out var address)) return false;
|
||||
if (System.Net.IPAddress.IsLoopback(address)) return true;
|
||||
var bytes = address.GetAddressBytes();
|
||||
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
return bytes[0] == 10
|
||||
|| bytes[0] == 172 && bytes[1] is >= 16 and <= 31
|
||||
|| bytes[0] == 192 && bytes[1] == 168;
|
||||
return address.IsIPv6LinkLocal || bytes[0] is >= 0xfc and <= 0xfd;
|
||||
}
|
||||
|
||||
internal static void ValidateIdentifier(string? value, string name, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value.Length > maxLength)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"{name} is required and bounded.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ReportingConfig
|
||||
{
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("configVersion")]
|
||||
public long ConfigVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("accounts")]
|
||||
public IReadOnlyList<AccountReportingConfig> Accounts { get; init; } = [];
|
||||
|
||||
public ReportingConfig NormalizeAndValidate()
|
||||
{
|
||||
if (ConfigVersion < 0)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "configVersion must not be negative.");
|
||||
var accounts = Accounts ?? [];
|
||||
var duplicateAccounts = accounts.GroupBy(account => account.AccountId, StringComparer.OrdinalIgnoreCase)
|
||||
.FirstOrDefault(group => group.Count() > 1);
|
||||
if (duplicateAccounts is not null)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Each account may occur only once in reporting configuration.");
|
||||
foreach (var account in accounts)
|
||||
account.ValidateAndNormalize();
|
||||
return this with { Accounts = accounts.ToArray() };
|
||||
}
|
||||
|
||||
public AccountReportingConfig? FindAccount(string accountId) =>
|
||||
Accounts.FirstOrDefault(account => string.Equals(account.AccountId, accountId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public sealed record AccountReportingConfig
|
||||
{
|
||||
[JsonPropertyName("accountId")]
|
||||
public string AccountId { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("allowedChats")]
|
||||
public IReadOnlyList<AllowedChat> AllowedChats { get; init; } = [];
|
||||
|
||||
public void ValidateAndNormalize()
|
||||
{
|
||||
RemoteAgentOptions.ValidateIdentifier(AccountId, "accountId", 200);
|
||||
var duplicate = (AllowedChats ?? []).GroupBy(chat => $"{chat.Type}:{chat.ChatId}", StringComparer.Ordinal)
|
||||
.FirstOrDefault(group => group.Count() > 1);
|
||||
if (duplicate is not null)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Each account chat identity and type may occur only once.");
|
||||
foreach (var chat in AllowedChats ?? [])
|
||||
chat.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AllowedChat
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public ReportingChatType Type { get; init; }
|
||||
|
||||
[JsonPropertyName("chatId")]
|
||||
public string ChatId { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("identityVerified")]
|
||||
public bool IdentityVerified { get; init; }
|
||||
|
||||
public void Validate() => RemoteAgentOptions.ValidateIdentifier(ChatId, "chatId", 512);
|
||||
}
|
||||
|
||||
public sealed record RemoteNodeRegistration(
|
||||
[property: JsonPropertyName("node_id")] string NodeId,
|
||||
[property: JsonPropertyName("agent_version")] string AgentVersion,
|
||||
[property: JsonPropertyName("protocol_version")] string ProtocolVersion,
|
||||
[property: JsonPropertyName("capabilities")] IReadOnlyList<string> Capabilities,
|
||||
[property: JsonPropertyName("reporting_config_version")] long ReportingConfigVersion,
|
||||
[property: JsonPropertyName("accounts")] IReadOnlyList<RemoteAccountSummary> Accounts);
|
||||
|
||||
public sealed record RemoteAccountSummary(
|
||||
[property: JsonPropertyName("account_id")] string AccountId,
|
||||
[property: JsonPropertyName("active")] bool Active,
|
||||
[property: JsonPropertyName("verified")] bool Verified,
|
||||
[property: JsonPropertyName("allowed_group_count")] int AllowedGroupCount,
|
||||
[property: JsonPropertyName("allowed_private_count")] int AllowedPrivateCount);
|
||||
|
||||
public sealed record RemoteNodeRegistrationResponse(
|
||||
[property: JsonPropertyName("node_id")] string NodeId,
|
||||
[property: JsonPropertyName("status")] RemoteNodeStatus Status,
|
||||
[property: JsonPropertyName("authenticated")] bool Authenticated,
|
||||
[property: JsonPropertyName("correlation_id")] string CorrelationId);
|
||||
|
||||
public sealed record RemoteHeartbeatResponse(
|
||||
[property: JsonPropertyName("node_id")] string NodeId,
|
||||
[property: JsonPropertyName("status")] RemoteNodeStatus Status,
|
||||
[property: JsonPropertyName("last_heartbeat_at")] DateTimeOffset LastHeartbeatAt,
|
||||
[property: JsonPropertyName("correlation_id")] string CorrelationId);
|
||||
|
||||
public sealed record RemoteTaskBatch(
|
||||
[property: JsonPropertyName("tasks")] IReadOnlyList<RemoteTaskEnvelope> Tasks);
|
||||
|
||||
public sealed record RemoteHeartbeat(
|
||||
[property: JsonPropertyName("node_id")] string NodeId,
|
||||
[property: JsonPropertyName("agent_version")] string AgentVersion,
|
||||
[property: JsonPropertyName("protocol_version")] string ProtocolVersion,
|
||||
[property: JsonPropertyName("node_status")] RemoteNodeStatus NodeStatus,
|
||||
[property: JsonPropertyName("wechat_running")] bool WechatRunning,
|
||||
[property: JsonPropertyName("wechat_logged_in")] bool WechatLoggedIn,
|
||||
[property: JsonPropertyName("session_locked")] bool SessionLocked,
|
||||
[property: JsonPropertyName("active_account_id")] string? ActiveAccountId,
|
||||
[property: JsonPropertyName("queue_length")] int QueueLength,
|
||||
[property: JsonPropertyName("reporting_config_version")] long ReportingConfigVersion,
|
||||
[property: JsonPropertyName("correlation_id")] string CorrelationId,
|
||||
[property: JsonPropertyName("last_error_code")] string? LastErrorCode = null);
|
||||
|
||||
public sealed record RemoteTaskEnvelope(
|
||||
[property: JsonPropertyName("task_id")] string TaskId,
|
||||
[property: JsonPropertyName("node_id")] string NodeId,
|
||||
[property: JsonPropertyName("account_id")] string AccountId,
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("idempotency_key")] string IdempotencyKey,
|
||||
[property: JsonPropertyName("payload")] JsonElement Payload,
|
||||
[property: JsonPropertyName("lease_generation")] long LeaseGeneration,
|
||||
[property: JsonPropertyName("lease_expires_at")] DateTimeOffset? LeaseExpiresAt,
|
||||
[property: JsonPropertyName("cancel_requested_at")] DateTimeOffset? CancelRequestedAt,
|
||||
[property: JsonPropertyName("status")] RemoteTaskStatus Status,
|
||||
[property: JsonPropertyName("state_version")] long StateVersion)
|
||||
{
|
||||
[JsonPropertyName("lease_owner")]
|
||||
public string? LeaseOwner { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updated_at")]
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("last_correlation_id")]
|
||||
public string? LastCorrelationId { get; init; }
|
||||
|
||||
[JsonPropertyName("result")]
|
||||
public RemoteTaskResult? Result { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RemoteTaskResult(
|
||||
[property: JsonPropertyName("task_id")] string TaskId,
|
||||
[property: JsonPropertyName("account_id")] string AccountId,
|
||||
[property: JsonPropertyName("lease_generation")] long LeaseGeneration,
|
||||
[property: JsonPropertyName("status")] RemoteTaskStatus Status,
|
||||
[property: JsonPropertyName("error_code")] string? ErrorCode,
|
||||
[property: JsonPropertyName("message")] string? Message,
|
||||
[property: JsonPropertyName("has_side_effect")] bool HasSideEffect,
|
||||
[property: JsonPropertyName("content")] JsonElement? Content,
|
||||
[property: JsonPropertyName("correlation_id")] string CorrelationId);
|
||||
|
||||
public sealed record RemoteReportingScope(
|
||||
[property: JsonPropertyName("chatId")] string ChatId,
|
||||
[property: JsonPropertyName("chatType")] ReportingChatType ChatType);
|
||||
|
||||
public sealed record RemoteMessageEvent(
|
||||
[property: JsonPropertyName("node_id")] string NodeId,
|
||||
[property: JsonPropertyName("account_id")] string AccountId,
|
||||
[property: JsonPropertyName("chat_id")] string ChatId,
|
||||
[property: JsonPropertyName("chat_type")] ReportingChatType ChatType,
|
||||
[property: JsonPropertyName("event_seq")] long EventSeq,
|
||||
[property: JsonPropertyName("event_type")] string EventType,
|
||||
[property: JsonPropertyName("occurred_at")] DateTimeOffset OccurredAt,
|
||||
[property: JsonPropertyName("content")] string? Content,
|
||||
[property: JsonPropertyName("config_version")] long ConfigVersion,
|
||||
[property: JsonPropertyName("authorization_version")] long AuthorizationVersion,
|
||||
[property: JsonPropertyName("correlation_id")] string CorrelationId,
|
||||
[property: JsonPropertyName("authorized")] bool Authorized = true);
|
||||
|
||||
public sealed record RemoteEventReceipt(
|
||||
[property: JsonPropertyName("accepted")] bool Accepted,
|
||||
[property: JsonPropertyName("duplicate")] bool Duplicate,
|
||||
[property: JsonPropertyName("event_id")] string? EventId,
|
||||
[property: JsonPropertyName("reason")] string? Reason);
|
||||
|
||||
public sealed record ReportingDecision(bool Allowed, string Reason, long AuthorizationVersion);
|
||||
|
||||
public sealed record RemoteTaskSubmission(
|
||||
[property: JsonPropertyName("node_id")] string NodeId,
|
||||
[property: JsonPropertyName("account_id")] string AccountId,
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("idempotency_key")] string IdempotencyKey,
|
||||
[property: JsonPropertyName("payload")] JsonElement Payload,
|
||||
[property: JsonPropertyName("not_after")] DateTimeOffset? NotAfter = null);
|
||||
|
||||
public sealed record RemoteTaskSubmissionResponse(
|
||||
[property: JsonPropertyName("task_id")] string TaskId,
|
||||
[property: JsonPropertyName("status")] RemoteTaskStatus Status,
|
||||
[property: JsonPropertyName("duplicate")] bool Duplicate,
|
||||
[property: JsonPropertyName("state_version")] long StateVersion);
|
||||
|
||||
public sealed record RemoteApiError(string Code, string Message, string CorrelationId);
|
||||
|
||||
public static class RemoteJson
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = Create();
|
||||
|
||||
private static JsonSerializerOptions Create()
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
|
||||
};
|
||||
options.Converters.Add(new JsonStringEnumConverter());
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public sealed class RemoteClientException(string code, int statusCode, string message, string correlationId)
|
||||
: Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
public int StatusCode { get; } = statusCode;
|
||||
public string CorrelationId { get; } = correlationId;
|
||||
}
|
||||
|
||||
public sealed class RemoteControlClient : IDisposable
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly bool _ownsHttp;
|
||||
private readonly RemoteAgentOptions _options;
|
||||
private readonly Uri? _baseAddress;
|
||||
private bool _authenticated;
|
||||
|
||||
public RemoteControlClient(RemoteAgentOptions options, HttpClient? httpClient = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_ownsHttp = httpClient is null;
|
||||
_http = httpClient ?? new HttpClient();
|
||||
if (Uri.TryCreate(options.AuthAddress, UriKind.Absolute, out var address))
|
||||
_baseAddress = new Uri($"{address.Scheme}://{address.Authority}/", UriKind.Absolute);
|
||||
AuthState = options.IsConfigured ? RemoteAuthState.NotConfigured : RemoteAuthState.NotConfigured;
|
||||
}
|
||||
|
||||
public RemoteAuthState AuthState { get; private set; }
|
||||
public string? LastErrorCode { get; private set; }
|
||||
public DateTimeOffset? LastSuccessAt { get; private set; }
|
||||
|
||||
public async Task<RemoteNodeRegistrationResponse> RegisterAsync(
|
||||
RemoteNodeRegistration registration,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConfigured();
|
||||
if (!string.Equals(registration.NodeId, _options.NodeId, StringComparison.Ordinal))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Registration nodeId does not match the configured nodeId.");
|
||||
AuthState = RemoteAuthState.Authenticating;
|
||||
try
|
||||
{
|
||||
var response = await SendAsync<RemoteNodeRegistrationResponse>(HttpMethod.Post, "/v1/nodes/register", registration, false, cancellationToken);
|
||||
_authenticated = response.Authenticated;
|
||||
AuthState = response.Authenticated ? RemoteAuthState.Authenticated : RemoteAuthState.AuthenticationFailed;
|
||||
LastSuccessAt = DateTimeOffset.UtcNow;
|
||||
LastErrorCode = null;
|
||||
return response;
|
||||
}
|
||||
catch (Exception exception) when (exception is RemoteClientException or HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
_authenticated = false;
|
||||
AuthState = RemoteAuthState.AuthenticationFailed;
|
||||
LastErrorCode = exception is RemoteClientException remote ? remote.Code : "ConnectionFailed";
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<RemoteHeartbeatResponse> HeartbeatAsync(RemoteHeartbeat heartbeat, CancellationToken cancellationToken = default) =>
|
||||
SendAuthenticatedAsync<RemoteHeartbeatResponse>(HttpMethod.Post,
|
||||
$"/v1/nodes/{Escape(heartbeat.NodeId)}/heartbeat", heartbeat, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<RemoteTaskEnvelope>> PollTasksAsync(
|
||||
string accountId,
|
||||
CancellationToken cancellationToken = default,
|
||||
int waitSeconds = 0)
|
||||
{
|
||||
RemoteAgentOptions.ValidateIdentifier(accountId, "accountId", 200);
|
||||
if (waitSeconds is < 0 or > 30)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "waitSeconds must be between 0 and 30.");
|
||||
var query = $"/v1/nodes/{Escape(_options.NodeId!)}/tasks?account_id={Uri.EscapeDataString(accountId)}";
|
||||
if (waitSeconds > 0) query += $"&wait_seconds={waitSeconds}";
|
||||
var batch = await SendAuthenticatedAsync<RemoteTaskBatch>(HttpMethod.Get, query, null, cancellationToken);
|
||||
return batch.Tasks ?? [];
|
||||
}
|
||||
|
||||
public Task<RemoteTaskEnvelope> AcknowledgeTaskAsync(RemoteTaskEnvelope task, CancellationToken cancellationToken = default) =>
|
||||
SendAuthenticatedAsync<RemoteTaskEnvelope>(HttpMethod.Post,
|
||||
$"/v1/nodes/{Escape(task.NodeId)}/tasks/{Escape(task.TaskId)}/ack", new
|
||||
{
|
||||
task_id = task.TaskId,
|
||||
account_id = task.AccountId,
|
||||
lease_generation = task.LeaseGeneration
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<RemoteTaskEnvelope> StartTaskAsync(RemoteTaskEnvelope task, CancellationToken cancellationToken = default) =>
|
||||
SendAuthenticatedAsync<RemoteTaskEnvelope>(HttpMethod.Post,
|
||||
$"/v1/nodes/{Escape(task.NodeId)}/tasks/{Escape(task.TaskId)}/start", new
|
||||
{
|
||||
task_id = task.TaskId,
|
||||
account_id = task.AccountId,
|
||||
lease_generation = task.LeaseGeneration
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<RemoteTaskEnvelope> RenewTaskAsync(RemoteTaskEnvelope task, CancellationToken cancellationToken = default) =>
|
||||
SendAuthenticatedAsync<RemoteTaskEnvelope>(HttpMethod.Post,
|
||||
$"/v1/nodes/{Escape(task.NodeId)}/tasks/{Escape(task.TaskId)}/renew", new
|
||||
{
|
||||
task_id = task.TaskId,
|
||||
account_id = task.AccountId,
|
||||
lease_generation = task.LeaseGeneration
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<RemoteTaskEnvelope> SendTaskResultAsync(
|
||||
RemoteTaskResult result,
|
||||
ReportingConfig? reportingConfig = null,
|
||||
string? chatId = null,
|
||||
ReportingChatType? chatType = null,
|
||||
IReadOnlyList<RemoteReportingScope>? chatScopes = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filtered = chatScopes is null
|
||||
? ReportingAuthorization.FilterTaskResult(reportingConfig, result, chatId, chatType, out _)
|
||||
: ReportingAuthorization.FilterTaskResultForChats(reportingConfig, result, chatScopes, out _);
|
||||
return SendAuthenticatedAsync<RemoteTaskEnvelope>(HttpMethod.Post,
|
||||
$"/v1/nodes/{Escape(_options.NodeId!)}/tasks/{Escape(result.TaskId)}/result", filtered, cancellationToken,
|
||||
RemoteProtocol.MaxTaskResultBytes);
|
||||
}
|
||||
|
||||
public async Task<RemoteEventReceipt> SubmitEventAsync(
|
||||
ReportingConfig reportingConfig,
|
||||
RemoteMessageEvent messageEvent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filtered = ReportingAuthorization.FilterEvent(reportingConfig, messageEvent, out var decision);
|
||||
if (filtered is null)
|
||||
return new RemoteEventReceipt(false, false, null, decision.Reason);
|
||||
return await SendAuthenticatedAsync<RemoteEventReceipt>(HttpMethod.Post,
|
||||
$"/v1/nodes/{Escape(messageEvent.NodeId)}/events", filtered, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int> FlushEventsAsync(
|
||||
RemoteEventQueue queue,
|
||||
ReportingConfig reportingConfig,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var sent = 0;
|
||||
foreach (var messageEvent in queue.PrepareForSend(reportingConfig))
|
||||
{
|
||||
var receipt = await SubmitEventAsync(reportingConfig, messageEvent, cancellationToken);
|
||||
if (!receipt.Accepted)
|
||||
continue;
|
||||
if (queue.MarkSent(messageEvent)) sent++;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
public Task<RemoteTaskEnvelope> AcknowledgeCancellationAsync(
|
||||
RemoteTaskEnvelope task,
|
||||
RemoteTaskStatus status,
|
||||
string? errorCode,
|
||||
string? message,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
SendTaskResultAsync(new RemoteTaskResult(task.TaskId, task.AccountId, task.LeaseGeneration,
|
||||
status, errorCode, message, false, null, NewCorrelationId()), cancellationToken: cancellationToken);
|
||||
|
||||
private Task<T> SendAuthenticatedAsync<T>(HttpMethod method, string path, object? body, CancellationToken cancellationToken,
|
||||
int maxBodyBytes = RemoteProtocol.MaxTaskPayloadBytes)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
return SendAsync<T>(method, path, body, true, cancellationToken, maxBodyBytes);
|
||||
}
|
||||
|
||||
private async Task<T> SendAsync<T>(HttpMethod method, string path, object? body, bool authenticated,
|
||||
CancellationToken cancellationToken, int maxBodyBytes = RemoteProtocol.MaxTaskPayloadBytes)
|
||||
{
|
||||
if (_baseAddress is null)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "authAddress is not a valid absolute URL.");
|
||||
using var request = new HttpRequestMessage(method, new Uri(_baseAddress, path.TrimStart('/')));
|
||||
request.Headers.TryAddWithoutValidation("X-Correlation-Id", NewCorrelationId());
|
||||
if (authenticated || method == HttpMethod.Post && path == "/v1/nodes/register")
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.Token);
|
||||
if (body is not null)
|
||||
{
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(body, RemoteJson.Options);
|
||||
if (bytes.Length > maxBodyBytes)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Remote request payload is too large.");
|
||||
request.Content = new ByteArrayContent(bytes);
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
|
||||
}
|
||||
|
||||
using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
var responseBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
var correlationId = response.Headers.TryGetValues("X-Correlation-Id", out var values)
|
||||
? values.FirstOrDefault() ?? request.Headers.GetValues("X-Correlation-Id").First()
|
||||
: request.Headers.GetValues("X-Correlation-Id").First();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = TryDeserializeError(responseBytes, correlationId);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
_authenticated = false;
|
||||
AuthState = RemoteAuthState.AuthenticationFailed;
|
||||
}
|
||||
LastErrorCode = error.Code;
|
||||
throw new RemoteClientException(error.Code, (int)response.StatusCode, error.Message, error.CorrelationId);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<T>(responseBytes, RemoteJson.Options);
|
||||
if (result is null) throw new JsonException("The control plane returned an empty response.");
|
||||
LastSuccessAt = DateTimeOffset.UtcNow;
|
||||
return result;
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new RemoteClientException("InvalidResponse", (int)response.StatusCode,
|
||||
"The control plane returned an invalid response.", correlationId) { Source = exception.Source };
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (!_options.IsConfigured)
|
||||
{
|
||||
AuthState = RemoteAuthState.NotConfigured;
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Remote authAddress, token and nodeId must all be configured.");
|
||||
}
|
||||
_options.Validate();
|
||||
}
|
||||
|
||||
private void EnsureAuthenticated()
|
||||
{
|
||||
EnsureConfigured();
|
||||
if (!_authenticated || AuthState != RemoteAuthState.Authenticated)
|
||||
throw new WxAgentException(WxAgentErrorCode.PermissionMismatch, "The node must authenticate before remote operations.");
|
||||
}
|
||||
|
||||
private static RemoteApiError TryDeserializeError(byte[] bytes, string correlationId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(bytes);
|
||||
var root = document.RootElement;
|
||||
if (root.TryGetProperty("error", out var error))
|
||||
{
|
||||
var code = error.TryGetProperty("code", out var codeValue) ? codeValue.GetString() : null;
|
||||
var message = error.TryGetProperty("message", out var messageValue) ? messageValue.GetString() : null;
|
||||
return new RemoteApiError(code ?? "RemoteRequestFailed", message ?? "Remote request failed.", correlationId);
|
||||
}
|
||||
}
|
||||
catch (JsonException) { }
|
||||
return new RemoteApiError("RemoteRequestFailed", "Remote request failed.", correlationId);
|
||||
}
|
||||
|
||||
private static string Escape(string value) => Uri.EscapeDataString(value);
|
||||
private static string NewCorrelationId() => Guid.NewGuid().ToString("N");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttp) _http.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public sealed record RemoteEventEnqueueResult(bool Accepted, string Reason, RemoteMessageEvent? Event);
|
||||
|
||||
public sealed class RemoteEventQueue
|
||||
{
|
||||
private const int DefaultMaxItems = 1000;
|
||||
private readonly string _path;
|
||||
private readonly int _maxItems;
|
||||
private readonly object _gate = new();
|
||||
private QueueState _state;
|
||||
|
||||
public RemoteEventQueue(string path, int maxItems = DefaultMaxItems)
|
||||
{
|
||||
if (maxItems is < 1 or > 100_000)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxItems));
|
||||
_path = Path.GetFullPath(path);
|
||||
_maxItems = maxItems;
|
||||
_state = Load(_path);
|
||||
}
|
||||
|
||||
public RemoteEventEnqueueResult Enqueue(
|
||||
ReportingConfig config,
|
||||
string nodeId,
|
||||
string accountId,
|
||||
string chatId,
|
||||
ReportingChatType chatType,
|
||||
string eventType,
|
||||
DateTimeOffset occurredAt,
|
||||
string? content)
|
||||
{
|
||||
var decision = ReportingAuthorization.Check(config, accountId, chatId, chatType, ReportingDataType.Message);
|
||||
if (!decision.Allowed)
|
||||
return new RemoteEventEnqueueResult(false, decision.Reason, null);
|
||||
RemoteAgentOptions.ValidateIdentifier(nodeId, "nodeId", 200);
|
||||
RemoteAgentOptions.ValidateIdentifier(eventType, "eventType", 80);
|
||||
RemoteAgentOptions.ValidateIdentifier(accountId, "accountId", 200);
|
||||
RemoteAgentOptions.ValidateIdentifier(chatId, "chatId", 512);
|
||||
if (content is { Length: > RemoteProtocol.MaxEventContentLength })
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Event content is too large.");
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state.Items.Count >= _maxItems)
|
||||
return new RemoteEventEnqueueResult(false, "ReportingQueueFull", null);
|
||||
var key = Key(accountId, chatId);
|
||||
_state.Sequences.TryGetValue(key, out var previous);
|
||||
var messageEvent = new RemoteMessageEvent(nodeId, accountId, chatId, chatType, checked(previous + 1), eventType,
|
||||
occurredAt, content, config.ConfigVersion, decision.AuthorizationVersion,
|
||||
Guid.NewGuid().ToString("N"), true);
|
||||
_state.Sequences[key] = messageEvent.EventSeq;
|
||||
_state.Items.Add(messageEvent);
|
||||
SaveLocked();
|
||||
return new RemoteEventEnqueueResult(true, "Queued", messageEvent);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<RemoteMessageEvent> PrepareForSend(ReportingConfig config)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var ready = new List<RemoteMessageEvent>(_state.Items.Count);
|
||||
var kept = new List<RemoteMessageEvent>(_state.Items.Count);
|
||||
foreach (var item in _state.Items)
|
||||
{
|
||||
var decision = ReportingAuthorization.Check(config, item.AccountId, item.ChatId, item.ChatType, ReportingDataType.Message);
|
||||
if (!decision.Allowed)
|
||||
continue;
|
||||
var refreshed = item with { AuthorizationVersion = decision.AuthorizationVersion, Authorized = true };
|
||||
ready.Add(refreshed);
|
||||
kept.Add(refreshed);
|
||||
}
|
||||
if (kept.Count != _state.Items.Count || !kept.SequenceEqual(_state.Items))
|
||||
{
|
||||
_state.Items = kept;
|
||||
SaveLocked();
|
||||
}
|
||||
return ready;
|
||||
}
|
||||
}
|
||||
|
||||
public bool MarkSent(RemoteMessageEvent messageEvent)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var index = _state.Items.FindIndex(item => item.AccountId == messageEvent.AccountId
|
||||
&& item.ChatId == messageEvent.ChatId
|
||||
&& item.ChatType == messageEvent.ChatType
|
||||
&& item.EventSeq == messageEvent.EventSeq
|
||||
&& Fingerprint(item) == Fingerprint(messageEvent));
|
||||
if (index < 0) return false;
|
||||
_state.Items.RemoveAt(index);
|
||||
SaveLocked();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public int PendingCount
|
||||
{
|
||||
get { lock (_gate) return _state.Items.Count; }
|
||||
}
|
||||
|
||||
private void SaveLocked()
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_path) ?? AppContext.BaseDirectory;
|
||||
Directory.CreateDirectory(directory);
|
||||
var temporary = _path + ".tmp-" + Guid.NewGuid().ToString("N");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temporary, JsonSerializer.Serialize(_state, RemoteJson.Options), Encoding.UTF8);
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
try { File.SetUnixFileMode(temporary, UnixFileMode.UserRead | UnixFileMode.UserWrite); }
|
||||
catch (PlatformNotSupportedException) { }
|
||||
}
|
||||
if (OperatingSystem.IsWindows() && File.Exists(_path)) File.Replace(temporary, _path, null);
|
||||
else File.Move(temporary, _path, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary)) File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static QueueState Load(string path)
|
||||
{
|
||||
if (!File.Exists(path)) return new QueueState();
|
||||
try
|
||||
{
|
||||
var state = JsonSerializer.Deserialize<QueueState>(File.ReadAllText(path), RemoteJson.Options) ?? new QueueState();
|
||||
state.Sequences ??= new(StringComparer.Ordinal);
|
||||
state.Items ??= [];
|
||||
return state;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or JsonException or NotSupportedException or ArgumentException)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Remote event queue could not be loaded; reporting is blocked.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Key(string accountId, string chatId) => $"{accountId}\u001f{chatId}";
|
||||
|
||||
private static string Fingerprint(RemoteMessageEvent value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes($"{value.NodeId}\n{value.AccountId}\n{value.ChatId}\n{value.ChatType}\n{value.EventSeq}\n{value.EventType}\n{value.OccurredAt:O}\n{value.Content}\n{value.ConfigVersion}");
|
||||
return Convert.ToHexString(SHA256.HashData(bytes));
|
||||
}
|
||||
|
||||
private sealed class QueueState
|
||||
{
|
||||
[JsonPropertyName("sequences")]
|
||||
public Dictionary<string, long> Sequences { get; set; } = new(StringComparer.Ordinal);
|
||||
|
||||
[JsonPropertyName("items")]
|
||||
public List<RemoteMessageEvent> Items { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public sealed record RemoteConfigurationAuditEntry
|
||||
{
|
||||
[JsonPropertyName("at")]
|
||||
public DateTimeOffset At { get; init; }
|
||||
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("configVersion")]
|
||||
public long ConfigVersion { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RemoteNodeConfiguration
|
||||
{
|
||||
[JsonPropertyName("remote")]
|
||||
public RemoteAgentOptions Remote { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("reporting")]
|
||||
public ReportingConfig Reporting { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("audit")]
|
||||
public IReadOnlyList<RemoteConfigurationAuditEntry> Audit { get; init; } = [];
|
||||
|
||||
public RemoteNodeConfiguration NormalizeAndValidate()
|
||||
{
|
||||
var reporting = (Reporting ?? new ReportingConfig()).NormalizeAndValidate();
|
||||
if (Remote is { IsConfigured: true }) Remote.Validate();
|
||||
return this with
|
||||
{
|
||||
Remote = Remote ?? new RemoteAgentOptions(),
|
||||
Reporting = reporting,
|
||||
Audit = (Audit ?? []).TakeLast(1000).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
public RemoteNodeConfiguration WithAudit(string action)
|
||||
{
|
||||
RemoteAgentOptions.ValidateIdentifier(action, "action", 120);
|
||||
return this with
|
||||
{
|
||||
Audit = (Audit ?? []).Append(new RemoteConfigurationAuditEntry
|
||||
{
|
||||
At = DateTimeOffset.UtcNow,
|
||||
Action = action,
|
||||
ConfigVersion = Reporting?.ConfigVersion ?? 0
|
||||
}).TakeLast(1000).ToArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class RemoteNodeConfigurationStore
|
||||
{
|
||||
private static readonly SemaphoreSlim Gate = new(1, 1);
|
||||
|
||||
public static async Task<RemoteNodeConfiguration> LoadAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
var configuration = await JsonSerializer.DeserializeAsync<RemoteNodeConfiguration>(stream, RemoteJson.Options, cancellationToken);
|
||||
return (configuration ?? new RemoteNodeConfiguration()).NormalizeAndValidate();
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return new RemoteNodeConfiguration();
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
return new RemoteNodeConfiguration();
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Remote node configuration is invalid JSON.", exception);
|
||||
}
|
||||
catch (WxAgentException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task SaveAsync(string path, RemoteNodeConfiguration configuration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = configuration.NormalizeAndValidate();
|
||||
var destination = Path.GetFullPath(path);
|
||||
var directory = Path.GetDirectoryName(destination) ?? AppContext.BaseDirectory;
|
||||
Directory.CreateDirectory(directory);
|
||||
await Gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var temporary = destination + ".tmp-" + Guid.NewGuid().ToString("N");
|
||||
try
|
||||
{
|
||||
await using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, normalized, RemoteJson.Options, cancellationToken);
|
||||
await stream.FlushAsync(cancellationToken);
|
||||
}
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
try { File.SetUnixFileMode(temporary, UnixFileMode.UserRead | UnixFileMode.UserWrite); }
|
||||
catch (PlatformNotSupportedException) { }
|
||||
}
|
||||
if (OperatingSystem.IsWindows() && File.Exists(destination)) File.Replace(temporary, destination, null);
|
||||
else File.Move(temporary, destination, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary)) File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public sealed record LocalRemoteTaskRecord
|
||||
{
|
||||
[JsonPropertyName("taskId")]
|
||||
public string TaskId { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("accountId")]
|
||||
public string AccountId { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("leaseGeneration")]
|
||||
public long LeaseGeneration { get; init; }
|
||||
|
||||
[JsonPropertyName("commandFingerprint")]
|
||||
public string CommandFingerprint { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public RemoteTaskStatus Status { get; init; }
|
||||
|
||||
[JsonPropertyName("result")]
|
||||
public RemoteTaskResult? Result { get; init; }
|
||||
|
||||
[JsonPropertyName("reportingScopes")]
|
||||
public IReadOnlyList<RemoteReportingScope> ReportingScopes { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("reported")]
|
||||
public bool Reported { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnreportedRemoteTask(
|
||||
RemoteTaskResult Result,
|
||||
IReadOnlyList<RemoteReportingScope> ReportingScopes);
|
||||
|
||||
public sealed class RemoteTaskLedger
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, LocalRemoteTaskRecord> _records;
|
||||
|
||||
public RemoteTaskLedger(string path)
|
||||
{
|
||||
_path = Path.GetFullPath(path);
|
||||
_records = Load(_path);
|
||||
RecoverIncomplete();
|
||||
}
|
||||
|
||||
public bool TryGet(string taskId, out LocalRemoteTaskRecord? record)
|
||||
{
|
||||
lock (_gate) return _records.TryGetValue(taskId, out record);
|
||||
}
|
||||
|
||||
public bool Accept(RemoteTaskEnvelope task)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var fingerprint = Fingerprint(task);
|
||||
if (_records.TryGetValue(task.TaskId, out var existing))
|
||||
{
|
||||
if (!string.Equals(existing.CommandFingerprint, fingerprint, StringComparison.Ordinal)
|
||||
|| !string.Equals(existing.AccountId, task.AccountId, StringComparison.Ordinal))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The same remote task ID was reused with different command parameters.");
|
||||
if (existing.Status is RemoteTaskStatus.Succeeded or RemoteTaskStatus.Failed or RemoteTaskStatus.Cancelled or RemoteTaskStatus.Expired or RemoteTaskStatus.ResultUnconfirmed)
|
||||
return false;
|
||||
if (existing.LeaseGeneration != task.LeaseGeneration)
|
||||
{
|
||||
_records[task.TaskId] = existing with
|
||||
{
|
||||
Status = RemoteTaskStatus.ResultUnconfirmed,
|
||||
Result = new RemoteTaskResult(task.TaskId, task.AccountId, task.LeaseGeneration,
|
||||
RemoteTaskStatus.ResultUnconfirmed, "LeaseGenerationChanged", "The node will not replay a task after its lease generation changed.", true, null, Guid.NewGuid().ToString("N")),
|
||||
Reported = false,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
SaveLocked();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
_records[task.TaskId] = new LocalRemoteTaskRecord
|
||||
{
|
||||
TaskId = task.TaskId,
|
||||
AccountId = task.AccountId,
|
||||
LeaseGeneration = task.LeaseGeneration,
|
||||
CommandFingerprint = fingerprint,
|
||||
Status = RemoteTaskStatus.Accepted,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
SaveLocked();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkAccepted(RemoteTaskEnvelope task)
|
||||
{
|
||||
lock (_gate) UpdateLocked(task.TaskId, task.AccountId, task.LeaseGeneration, RemoteTaskStatus.Accepted, null, false);
|
||||
}
|
||||
|
||||
public void MarkRunning(RemoteTaskEnvelope task)
|
||||
{
|
||||
lock (_gate) UpdateLocked(task.TaskId, task.AccountId, task.LeaseGeneration, RemoteTaskStatus.Running, null, false);
|
||||
}
|
||||
|
||||
public void Complete(RemoteTaskResult result, IReadOnlyList<RemoteReportingScope>? chatScopes = null)
|
||||
{
|
||||
if (result.Status is not (RemoteTaskStatus.Succeeded or RemoteTaskStatus.Failed or RemoteTaskStatus.Cancelled or RemoteTaskStatus.Expired or RemoteTaskStatus.ResultUnconfirmed))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Only a terminal result can be persisted in the remote task ledger.");
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_records.TryGetValue(result.TaskId, out var existing))
|
||||
{
|
||||
existing = new LocalRemoteTaskRecord
|
||||
{
|
||||
TaskId = result.TaskId,
|
||||
AccountId = result.AccountId,
|
||||
LeaseGeneration = result.LeaseGeneration,
|
||||
CommandFingerprint = "unknown"
|
||||
};
|
||||
}
|
||||
if (!string.Equals(existing.AccountId, result.AccountId, StringComparison.Ordinal)
|
||||
|| existing.LeaseGeneration > result.LeaseGeneration)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The remote task result does not match the local task ledger.");
|
||||
var scopes = chatScopes?.ToArray() ?? [];
|
||||
_records[result.TaskId] = existing with
|
||||
{
|
||||
AccountId = result.AccountId,
|
||||
LeaseGeneration = result.LeaseGeneration,
|
||||
Status = result.Status,
|
||||
Result = scopes.Length == 0 ? result with { Content = null } : result,
|
||||
ReportingScopes = scopes,
|
||||
Reported = false,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
SaveLocked();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<RemoteTaskResult> UnreportedResults()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _records.Values
|
||||
.Where(record => !record.Reported && record.Result is not null)
|
||||
.Select(record => record.Result!)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<UnreportedRemoteTask> UnreportedResultsWithScopes()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _records.Values
|
||||
.Where(record => !record.Reported && record.Result is not null)
|
||||
.Select(record => new UnreportedRemoteTask(record.Result!, record.ReportingScopes))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkReported(string taskId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_records.TryGetValue(taskId, out var existing) && existing.Result is not null)
|
||||
{
|
||||
_records[taskId] = existing with { Reported = true, UpdatedAt = DateTimeOffset.UtcNow };
|
||||
SaveLocked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RecoverIncomplete()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var changed = false;
|
||||
foreach (var pair in _records.ToArray())
|
||||
{
|
||||
if (pair.Value.Status is not (RemoteTaskStatus.Accepted or RemoteTaskStatus.Running)) continue;
|
||||
_records[pair.Key] = pair.Value with
|
||||
{
|
||||
Status = RemoteTaskStatus.ResultUnconfirmed,
|
||||
Result = new RemoteTaskResult(pair.Value.TaskId, pair.Value.AccountId, pair.Value.LeaseGeneration,
|
||||
RemoteTaskStatus.ResultUnconfirmed, "AgentRestartedWithIncompleteTask", "Execution was not replayed.", true, null, Guid.NewGuid().ToString("N")),
|
||||
Reported = false,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
if (changed) SaveLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateLocked(string taskId, string accountId, long leaseGeneration, RemoteTaskStatus status, RemoteTaskResult? result, bool reported)
|
||||
{
|
||||
if (!_records.TryGetValue(taskId, out var existing))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The remote task was not accepted into the local ledger.");
|
||||
if (!string.Equals(existing.AccountId, accountId, StringComparison.Ordinal) || existing.LeaseGeneration != leaseGeneration)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The remote task lease does not match the local ledger.");
|
||||
_records[taskId] = existing with { Status = status, Result = result, Reported = reported, UpdatedAt = DateTimeOffset.UtcNow };
|
||||
SaveLocked();
|
||||
}
|
||||
|
||||
private void SaveLocked()
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_path) ?? AppContext.BaseDirectory;
|
||||
Directory.CreateDirectory(directory);
|
||||
var temporary = _path + ".tmp-" + Guid.NewGuid().ToString("N");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temporary, JsonSerializer.Serialize(_records.Values, RemoteJson.Options), Encoding.UTF8);
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
try { File.SetUnixFileMode(temporary, UnixFileMode.UserRead | UnixFileMode.UserWrite); }
|
||||
catch (PlatformNotSupportedException) { }
|
||||
}
|
||||
if (OperatingSystem.IsWindows() && File.Exists(_path)) File.Replace(temporary, _path, null);
|
||||
else File.Move(temporary, _path, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary)) File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, LocalRemoteTaskRecord> Load(string path)
|
||||
{
|
||||
if (!File.Exists(path)) return new(StringComparer.Ordinal);
|
||||
try
|
||||
{
|
||||
var records = JsonSerializer.Deserialize<IReadOnlyList<LocalRemoteTaskRecord>>(File.ReadAllText(path), RemoteJson.Options) ?? [];
|
||||
return records.ToDictionary(record => record.TaskId, StringComparer.Ordinal);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or JsonException or NotSupportedException or ArgumentException)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Remote task ledger could not be loaded; remote task execution is blocked.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Fingerprint(RemoteTaskEnvelope task)
|
||||
{
|
||||
var value = $"{task.AccountId}\n{task.Kind}\n{task.Payload.GetRawText()}";
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public static class ReportingAuthorization
|
||||
{
|
||||
public static ReportingDecision Check(
|
||||
ReportingConfig? config,
|
||||
string accountId,
|
||||
string chatId,
|
||||
ReportingChatType chatType,
|
||||
ReportingDataType dataType)
|
||||
{
|
||||
if (config is null)
|
||||
return Denied("ReportingConfigInvalid");
|
||||
try
|
||||
{
|
||||
config.NormalizeAndValidate();
|
||||
}
|
||||
catch (WxAgentException)
|
||||
{
|
||||
return Denied("ReportingConfigInvalid");
|
||||
}
|
||||
|
||||
if (!config.Enabled)
|
||||
return Denied("ReportingDisabled");
|
||||
if (string.IsNullOrWhiteSpace(accountId) || string.IsNullOrWhiteSpace(chatId))
|
||||
return Denied("ChatIdentityUnconfirmed");
|
||||
|
||||
var account = config.FindAccount(accountId);
|
||||
if (account is null || !account.Enabled)
|
||||
return Denied("AccountNotAuthorized");
|
||||
|
||||
var chat = account.AllowedChats.FirstOrDefault(candidate =>
|
||||
candidate.Type == chatType && string.Equals(candidate.ChatId, chatId, StringComparison.Ordinal));
|
||||
if (chat is null)
|
||||
return Denied("ChatNotAuthorized");
|
||||
if (!chat.Enabled)
|
||||
return Denied("ChatNotAuthorized");
|
||||
if (!chat.IdentityVerified)
|
||||
return Denied("ChatIdentityUnconfirmed");
|
||||
if (!IsDataTypeAllowed(dataType))
|
||||
return Denied("DataTypeNotAuthorized");
|
||||
|
||||
return new ReportingDecision(true, "Authorized", config.ConfigVersion);
|
||||
}
|
||||
|
||||
public static bool IsAllowed(
|
||||
ReportingConfig? config,
|
||||
string accountId,
|
||||
string chatId,
|
||||
ReportingChatType chatType,
|
||||
ReportingDataType dataType) => Check(config, accountId, chatId, chatType, dataType).Allowed;
|
||||
|
||||
public static RemoteMessageEvent? FilterEvent(
|
||||
ReportingConfig? config,
|
||||
RemoteMessageEvent messageEvent,
|
||||
out ReportingDecision decision)
|
||||
{
|
||||
decision = Check(config, messageEvent.AccountId, messageEvent.ChatId, messageEvent.ChatType, ReportingDataType.Message);
|
||||
if (!decision.Allowed)
|
||||
return null;
|
||||
|
||||
RemoteAgentOptions.ValidateIdentifier(messageEvent.NodeId, "nodeId", 200);
|
||||
RemoteAgentOptions.ValidateIdentifier(messageEvent.EventType, "eventType", 80);
|
||||
RemoteAgentOptions.ValidateIdentifier(messageEvent.CorrelationId, "correlationId", 128);
|
||||
if (messageEvent.EventSeq <= 0)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "eventSeq must be positive.");
|
||||
if (messageEvent.Content is { Length: > RemoteProtocol.MaxEventContentLength })
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Event content is too large.");
|
||||
if (messageEvent.Content is null)
|
||||
return messageEvent with { AuthorizationVersion = decision.AuthorizationVersion, Authorized = true };
|
||||
return messageEvent with { AuthorizationVersion = decision.AuthorizationVersion, Authorized = true };
|
||||
}
|
||||
|
||||
public static RemoteTaskResult FilterTaskResult(
|
||||
ReportingConfig? config,
|
||||
RemoteTaskResult result,
|
||||
string? chatId,
|
||||
ReportingChatType? chatType,
|
||||
out ReportingDecision decision)
|
||||
{
|
||||
if (result.Content is null || string.IsNullOrWhiteSpace(chatId) || chatType is null)
|
||||
{
|
||||
decision = new ReportingDecision(true, "ControlMetadataOnly", config?.ConfigVersion ?? 0);
|
||||
return result with { Content = null, Message = result.Message is null ? null : "Control metadata only." };
|
||||
}
|
||||
|
||||
decision = Check(config, result.AccountId, chatId, chatType.Value, ReportingDataType.TaskResult);
|
||||
return decision.Allowed
|
||||
? result
|
||||
: result with { Content = null, Message = "Control metadata only." };
|
||||
}
|
||||
|
||||
public static RemoteTaskResult FilterTaskResultForChats(
|
||||
ReportingConfig? config,
|
||||
RemoteTaskResult result,
|
||||
IReadOnlyList<RemoteReportingScope> chatScopes,
|
||||
out ReportingDecision decision)
|
||||
{
|
||||
if (result.Content is null)
|
||||
{
|
||||
decision = new ReportingDecision(true, "ControlMetadataOnly", config?.ConfigVersion ?? 0);
|
||||
return result;
|
||||
}
|
||||
if (chatScopes.Count == 0)
|
||||
{
|
||||
decision = Denied("ChatNotAuthorized");
|
||||
return result with { Content = null, Message = "Control metadata only." };
|
||||
}
|
||||
|
||||
var decisions = chatScopes.Select(scope => Check(config, result.AccountId, scope.ChatId, scope.ChatType, ReportingDataType.TaskResult)).ToArray();
|
||||
decision = decisions.FirstOrDefault(item => !item.Allowed) ?? new ReportingDecision(true, "Authorized", config?.ConfigVersion ?? 0);
|
||||
return decision.Allowed
|
||||
? result
|
||||
: result with { Content = null, Message = "Control metadata only." };
|
||||
}
|
||||
|
||||
private static bool IsDataTypeAllowed(ReportingDataType dataType) => dataType is ReportingDataType.Message or ReportingDataType.TaskResult;
|
||||
|
||||
private static ReportingDecision Denied(string reason) => new(false, reason, 0);
|
||||
}
|
||||
|
||||
public static class ReportingConfigStore
|
||||
{
|
||||
private static readonly SemaphoreSlim Gate = new(1, 1);
|
||||
|
||||
public static async Task<ReportingConfig> LoadAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
var config = await JsonSerializer.DeserializeAsync<ReportingConfig>(stream, RemoteJson.Options, cancellationToken);
|
||||
return (config ?? new ReportingConfig()).NormalizeAndValidate();
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return new ReportingConfig();
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
return new ReportingConfig();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new ReportingConfig();
|
||||
}
|
||||
catch (WxAgentException)
|
||||
{
|
||||
return new ReportingConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task SaveAsync(string path, ReportingConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = config.NormalizeAndValidate();
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
var directory = Path.GetDirectoryName(fullPath) ?? AppContext.BaseDirectory;
|
||||
Directory.CreateDirectory(directory);
|
||||
await Gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var temporary = fullPath + ".tmp-" + Guid.NewGuid().ToString("N");
|
||||
try
|
||||
{
|
||||
await using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, normalized, RemoteJson.Options, cancellationToken);
|
||||
await stream.FlushAsync(cancellationToken);
|
||||
}
|
||||
ReplaceFile(temporary, fullPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary)) File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static ReportingConfig Update(ReportingConfig current, Func<ReportingConfig, ReportingConfig> change)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(current);
|
||||
ArgumentNullException.ThrowIfNull(change);
|
||||
var changed = change(current) with { ConfigVersion = checked(current.ConfigVersion + 1) };
|
||||
return changed.NormalizeAndValidate();
|
||||
}
|
||||
|
||||
private static void ReplaceFile(string temporary, string destination)
|
||||
{
|
||||
if (OperatingSystem.IsWindows() && File.Exists(destination))
|
||||
{
|
||||
File.Replace(temporary, destination, null);
|
||||
return;
|
||||
}
|
||||
File.Move(temporary, destination, true);
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
try { File.SetUnixFileMode(destination, UnixFileMode.UserRead | UnixFileMode.UserWrite); }
|
||||
catch (PlatformNotSupportedException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ public static class WechatLocators
|
||||
{
|
||||
public const string MainView = "MainView";
|
||||
public const string MainTabBar = "MainView.main_tabbar";
|
||||
public const string SettingsMenuButton = "MainView.main_tabbar.tabbar_setting";
|
||||
public const string PreferenceWindow = "PreferenceWindow";
|
||||
public const string ProfileWindowTitle = "Weixin";
|
||||
public const string ProfileDisplayName = "right_v_view.nickname_button_view.display_name_text";
|
||||
public const string ProfileWechatId = "right_v_view.user_info_center_view.basic_line_view.ProfileTextView";
|
||||
|
||||
@@ -14,10 +14,11 @@ public static class WechatSessionParser
|
||||
.Where(element => element.AutomationId.StartsWith("session_item_", StringComparison.Ordinal) &&
|
||||
!string.IsNullOrWhiteSpace(element.Name))
|
||||
.DistinctBy(element => element.AutomationId, StringComparer.Ordinal)
|
||||
.Select(element => new WechatSessionSnapshot(
|
||||
element.Name,
|
||||
element.AutomationId,
|
||||
string.Equals(element.Name, currentName, StringComparison.Ordinal)))
|
||||
.Select(element =>
|
||||
{
|
||||
var name = element.Name.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() ?? element.Name;
|
||||
return new WechatSessionSnapshot(name, element.AutomationId, string.Equals(name, currentName, StringComparison.Ordinal));
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ public enum WxAgentErrorCode
|
||||
SessionLocked,
|
||||
UnsupportedWechatVersion,
|
||||
ResultUnconfirmed,
|
||||
AccountContextUnconfirmed,
|
||||
OperationCancelled,
|
||||
InvalidOperationState,
|
||||
DataRootNotFound,
|
||||
|
||||
Reference in New Issue
Block a user