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, WaitingForClient, 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("tokenFile")] public string? TokenFile { get; init; } [JsonPropertyName("serverCaFile")] public string? ServerCaFile { get; init; } [JsonPropertyName("clientCertificateFile")] public string? ClientCertificateFile { get; init; } [JsonPropertyName("clientCertificateKeyFile")] public string? ClientCertificateKeyFile { 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(TokenFile)) && !string.IsNullOrWhiteSpace(NodeId); public string TokenState => !string.IsNullOrWhiteSpace(TokenFile) ? "file-configured" : string.IsNullOrWhiteSpace(Token) ? "not-configured" : "configured"; public string GetToken() { string? token; if (string.IsNullOrWhiteSpace(TokenFile)) { token = Token; } else { try { token = File.ReadAllText(TokenFile).Trim(); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException) { throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "tokenFile could not be read.", exception); } } if (string.IsNullOrWhiteSpace(token)) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "token or tokenFile must contain a non-empty token."); return token; } public void Validate() { if (string.IsNullOrWhiteSpace(AuthAddress) || (!string.IsNullOrWhiteSpace(Token) && !string.IsNullOrWhiteSpace(TokenFile)) || (!IsConfigured) || string.IsNullOrWhiteSpace(NodeId)) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "authAddress, token or tokenFile 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); var token = GetToken(); if (token.Any(char.IsWhiteSpace)) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "token must not contain whitespace."); if (!string.IsNullOrWhiteSpace(TokenFile) && !File.Exists(TokenFile)) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "tokenFile does not exist."); ValidateOptionalFile(ServerCaFile, "serverCaFile"); ValidateOptionalFile(ClientCertificateFile, "clientCertificateFile"); ValidateOptionalFile(ClientCertificateKeyFile, "clientCertificateKeyFile"); if (!string.IsNullOrWhiteSpace(ClientCertificateKeyFile) && string.IsNullOrWhiteSpace(ClientCertificateFile)) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "clientCertificateFile is required with clientCertificateKeyFile."); if (!string.IsNullOrWhiteSpace(ClientCertificateFile) && string.IsNullOrWhiteSpace(ClientCertificateKeyFile)) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "clientCertificateKeyFile is required for PEM client certificates."); } public RemoteAgentOptions Redacted() => this with { Token = string.IsNullOrWhiteSpace(Token) ? null : "" }; private static void ValidateOptionalFile(string? path, string name) { if (!string.IsNullOrWhiteSpace(path) && !File.Exists(path)) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"{name} does not exist."); } 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 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 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 Capabilities, [property: JsonPropertyName("reporting_config_version")] long ReportingConfigVersion, [property: JsonPropertyName("accounts")] IReadOnlyList Accounts, [property: JsonPropertyName("connection_id")] string? ConnectionId = null); 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) { [JsonPropertyName("allowed_chats")] public IReadOnlyList AllowedChats { get; init; } = []; } public sealed record RemoteAllowedChatSummary( [property: JsonPropertyName("chat_id")] string ChatId, [property: JsonPropertyName("chat_type")] ReportingChatType ChatType); 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, [property: JsonPropertyName("connection_id")] string? ConnectionId = null); public sealed record RemoteDataSyncStatus( [property: JsonPropertyName("state")] string State, [property: JsonPropertyName("source_generation")] string SourceGeneration, [property: JsonPropertyName("confirmed_sequence")] long ConfirmedSequence, [property: JsonPropertyName("confirmed_cursor")] string ConfirmedCursor, [property: JsonPropertyName("last_success_at")] DateTimeOffset? LastSuccessAt = null); 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, [property: JsonPropertyName("connection_id")] string? ConnectionId = null); public sealed record RemoteTaskBatch( [property: JsonPropertyName("tasks")] IReadOnlyList 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, [property: JsonPropertyName("connection_id")] string? ConnectionId = 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; } }