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 && (candidate.ChatId == "*" || 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 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 LoadAsync(string path, CancellationToken cancellationToken = default) { try { await using var stream = File.OpenRead(path); var config = await JsonSerializer.DeserializeAsync(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 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) { } } } }