feat: add remote control plane and whitelist reads
Build web service image / build (push) Successful in 1m53s

This commit is contained in:
2026-09-12 09:46:05 +08:00
parent c86ba9c4d7
commit 13c31fc902
53 changed files with 9878 additions and 43 deletions
@@ -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);
}
}
+332
View File
@@ -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();
}
}
+162
View File
@@ -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();
}
}
}
+252
View File
@@ -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";
+5 -4
View File
@@ -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,