using System.Globalization; using AIStudio.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.Services; using Lua; namespace AIStudio.Tools.PluginSystem; public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginType type) : PluginBase(isInternal, state, type) { private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginConfiguration).Namespace, nameof(PluginConfiguration)); private static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService(); private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginConfiguration)); private List configObjects = []; private List mandatoryInfos = []; private List introductions = []; /// /// The list of configuration objects. Configuration objects are, e.g., providers or chat templates. /// public IEnumerable ConfigObjects => this.configObjects; /// /// The list of mandatory infos provided by this configuration plugin. /// Mandatory infos are live plugin content and are not persisted to ConfigurationData. /// public IReadOnlyList MandatoryInfos => this.mandatoryInfos; /// /// The list of introductions provided by this configuration plugin. /// Introductions are live plugin content and are not persisted to ConfigurationData. /// public IReadOnlyList Introductions => this.introductions; /// /// True/false when explicitly configured in the plugin, otherwise null. /// public bool? DeployedUsingConfigServer { get; } = ReadDeployedUsingConfigServer(state); /// /// The priority of this configuration plugin. Defaults to zero when the plugin declares none. /// /// /// Configuration plugins with a higher priority are applied later and therefore win when two of /// them manage the same setting or define the same configuration object. This lets an /// organization deploy one base configuration for everybody and additional configurations which /// refine it, e.g. per department. /// public int Priority { get; } = ReadPriority(state); /// /// How many settings this configuration plugin declares. /// /// /// This counts the entries of the Lua SETTINGS table, without the .AllowUserOverride /// companions. We need it for the import preview: a dry run does not lock anything, so the /// number of settings the plugin would take over cannot be read from the managed configuration /// at that point. /// public int DeclaredSettingsCount { get; private set; } public async Task InitializeAsync(bool dryRun) { if(!this.TryProcessConfiguration(dryRun, out var issue)) this.PluginIssues.Add(issue); if (!dryRun) { // Store any decrypted API keys from enterprise configuration in the OS keyring: await StoreEnterpriseApiKeysAsync(); await StoreEnterpriseSecretsAsync(); await SettingsManagerAccess.StoreSettings(); await MessageBus.INSTANCE.SendMessage(null, Event.CONFIGURATION_CHANGED); } } /// /// Stores any pending enterprise secrets in the OS keyring. /// private static async Task StoreEnterpriseSecretsAsync() { var pendingSecrets = PendingEnterpriseSecrets.GetAndClear(); if (pendingSecrets.Count == 0) return; LOG.LogInformation($"Storing {pendingSecrets.Count} enterprise secret(s) in the OS keyring."); var rustService = Program.SERVICE_PROVIDER.GetRequiredService(); foreach (var pendingSecret in pendingSecrets) { try { var secretId = new TemporarySecretId(pendingSecret.SecretId, pendingSecret.SecretName); var result = await rustService.SetSecret(secretId, pendingSecret.SecretData, pendingSecret.StoreType); if (result.Success) LOG.LogDebug($"Successfully stored enterprise secret for '{pendingSecret.SecretName}' in the OS keyring."); else LOG.LogWarning($"Failed to store enterprise secret for '{pendingSecret.SecretName}': {result.Issue}"); } catch (Exception ex) { LOG.LogError(ex, $"Exception while storing enterprise secret for '{pendingSecret.SecretName}'."); } } } /// /// Stores any pending enterprise API keys in the OS keyring. /// private static async Task StoreEnterpriseApiKeysAsync() { var pendingKeys = PendingEnterpriseApiKeys.GetAndClear(); if (pendingKeys.Count == 0) return; LOG.LogInformation($"Storing {pendingKeys.Count} enterprise API key(s) in the OS keyring."); var rustService = Program.SERVICE_PROVIDER.GetRequiredService(); foreach (var pendingKey in pendingKeys) { try { // Create a temporary secret ID object for storing the key: var secretId = new TemporarySecretId(pendingKey.SecretId, pendingKey.SecretName); var result = await rustService.SetAPIKey(secretId, pendingKey.ApiKey, pendingKey.StoreType); if (result.Success) LOG.LogDebug($"Successfully stored enterprise API key for '{pendingKey.SecretName}' in the OS keyring."); else LOG.LogWarning($"Failed to store enterprise API key for '{pendingKey.SecretName}': {result.Issue}"); } catch (Exception ex) { LOG.LogError(ex, $"Exception while storing enterprise API key for '{pendingKey.SecretName}'."); } } } /// /// Temporary implementation of ISecretId for storing enterprise API keys. /// private sealed record TemporarySecretId(string SecretId, string SecretName) : ISecretId; private static bool? ReadDeployedUsingConfigServer(LuaState state) { if (state.Environment["DEPLOYED_USING_CONFIG_SERVER"].TryRead(out var deployedUsingConfigServer)) return deployedUsingConfigServer; return null; } private static int ReadPriority(LuaState state) { if (state.Environment["PRIORITY"].TryRead(out var priority)) return priority; return 0; } /// /// Counts the settings a configuration plugin declares, ignoring the .AllowUserOverride /// companion keys: those refine a setting instead of adding one. /// private static int CountDeclaredSettings(LuaTable settingsTable) { const string USER_OVERRIDE_SUFFIX = ".AllowUserOverride"; var count = 0; var previousKey = LuaValue.Nil; while (settingsTable.TryGetNext(previousKey, out var pair)) { previousKey = pair.Key; if (pair.Key.TryRead(out var settingName) && !settingName.EndsWith(USER_OVERRIDE_SUFFIX, StringComparison.Ordinal)) count++; } return count; } /// /// Tries to initialize the UI text content of the plugin. /// /// When true, the method will not apply any changes but only check if the configuration can be read. /// The error message, when the UI text content could not be read. /// True, when the UI text content could be read successfully. private bool TryProcessConfiguration(bool dryRun, out string message) { this.configObjects.Clear(); this.mandatoryInfos.Clear(); this.introductions.Clear(); // Ensure that the main CONFIG table exists and is a valid Lua table: if (!this.State.Environment["CONFIG"].TryRead(out var mainTable)) { message = TB("The CONFIG table does not exist or is not a valid table."); return false; } // Check for the main SETTINGS table: if (!mainTable.TryGetValue("SETTINGS", out var settingsValue) || !settingsValue.TryRead(out var settingsTable)) { message = TB("The SETTINGS table does not exist or is not a valid table."); return false; } this.DeclaredSettingsCount = CountDeclaredSettings(settingsTable); // Config: check for updates, and if so, how often? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.UpdateInterval, this.Id, settingsTable, dryRun); // Config: how should updates be installed? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.UpdateInstallation, this.Id, settingsTable, dryRun); // Config: what should be the start page? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.StartPage, this.Id, settingsTable, dryRun); // Config: show built-in introduction on the home page? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowIntroduction, this.Id, settingsTable, dryRun); // Config: show quick start guide on the home page? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowQuickStartGuide, this.Id, settingsTable, dryRun); // Config: show last changelog on the home page? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowLastChangelog, this.Id, settingsTable, dryRun); // Config: show vision panel on the home page? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowVision, this.Id, settingsTable, dryRun); // Config: allow the user to add providers? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToAddProvider, this.Id, settingsTable, dryRun); // Config: allow the user to import plugin archives? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportPlugins, this.Id, settingsTable, dryRun); // Config: allow the user to import configuration plugin archives? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportConfigurationPlugins, this.Id, settingsTable, dryRun); // Config: allow the user to share or export plugins? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToSharePlugins, this.Id, settingsTable, dryRun); // Config: show administration settings? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowAdminSettings, this.Id, settingsTable, dryRun); // Config: preview features visibility ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreviewVisibility, this.Id, settingsTable, dryRun); // Config: enabled preview features (plugin contribution; users can enable additional features) ManagedConfiguration.TryProcessConfigurationWithPluginContribution(x => x.App, x => x.EnabledPreviewFeatures, this.Id, settingsTable, dryRun); // Config: hide some assistants? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.HiddenAssistants, this.Id, settingsTable, dryRun); // Config: global voice recording shortcut ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShortcutVoiceRecording, this.Id, settingsTable, dryRun); // Config: timeout for external HTTP requests ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.HttpClientTimeoutSeconds, this.Id, settingsTable, dryRun); // Config: custom root certificates for external HTTP requests ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ExternalHttpCustomRootCertificatesEnabled, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ExternalHttpCustomRootCertificateBundlePath, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ExternalHttpCustomRootCertificateAllowedHosts, this.Id, settingsTable, dryRun); // Config: provider confidence settings ManagedConfiguration.TryProcessConfiguration(x => x.Confidence, x => x.EnforceGlobalMinimumConfidence, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.Confidence, x => x.GlobalMinimumConfidence, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.Confidence, x => x.ShowProviderConfidence, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.Confidence, x => x.ConfidenceScheme, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.Confidence, x => x.CustomConfidenceScheme, this.Id, settingsTable, dryRun); // Config: data source security settings ManagedConfiguration.TryProcessConfiguration(x => x.DataSourceSecurity, x => x.TrustedProviderIds, this.Id, settingsTable, dryRun); // Config: data source selection agent settings ManagedConfiguration.TryProcessConfiguration(x => x.AgentDataSourceSelection, x => x.PreselectAgentOptions, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.AgentDataSourceSelection, x => x.PreselectedAgentProvider, Guid.Empty, this.Id, settingsTable, dryRun); // Config: retrieval context validation agent settings ManagedConfiguration.TryProcessConfiguration(x => x.AgentRetrievalContextValidation, x => x.EnableRetrievalContextValidation, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.AgentRetrievalContextValidation, x => x.PreselectAgentOptions, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.AgentRetrievalContextValidation, x => x.PreselectedAgentProvider, Guid.Empty, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.AgentRetrievalContextValidation, x => x.NumParallelValidations, this.Id, settingsTable, dryRun); // Config: assistant plugin audit settings ManagedConfiguration.TryProcessConfiguration(x => x.AssistantPluginAudit, x => x.RequireAuditBeforeActivation, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.AssistantPluginAudit, x => x.PreselectedAgentProvider, Guid.Empty, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.AssistantPluginAudit, x => x.MinimumLevel, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.AssistantPluginAudit, x => x.BlockActivationBelowMinimum, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.AssistantPluginAudit, x => x.AutomaticallyAuditAssistants, this.Id, settingsTable, dryRun); // Config: enterprise-managed approvals for assistant plugins this.TryProcessEnterpriseApprovedAssistantPlugins(settingsTable, dryRun); // Handle configured LLM providers: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun); // Handle configured transcription providers: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, x => x.NextTranscriptionNum, mainTable, this.Id, ref this.configObjects, dryRun); // Handle configured embedding providers: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, x => x.NextEmbeddingNum, mainTable, this.Id, ref this.configObjects, dryRun); // Handle configured chat templates: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, x => x.NextChatTemplateNum, mainTable, this.Id, ref this.configObjects, dryRun, this.PluginPath); // Handle configured data sources: PluginConfigurationObject.TryParseDataSources(mainTable, this.Id, ref this.configObjects, dryRun); // Handle configured profiles: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.PROFILE, x => x.Profiles, x => x.NextProfileNum, mainTable, this.Id, ref this.configObjects, dryRun); // Handle configured document analysis policies: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, x => x.NextDocumentAnalysisPolicyNum, mainTable, this.Id, ref this.configObjects, dryRun); // Handle configured mandatory infos: this.TryReadMandatoryInfos(mainTable); // Handle configured introductions: this.TryReadIntroductions(mainTable); // Config: preselected provider? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun); // Config: preselected profile? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreselectedProfile, Guid.Empty, this.Id, settingsTable, dryRun); // Config: preselected chat options? ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectOptions, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedProfile, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedChatTemplate, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedDataSourcesDisabled, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedDataSourcesAutomaticSelection, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedDataSourcesAutomaticValidation, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedDataSourceIds, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.SendToChatDataSourceBehavior, this.Id, settingsTable, dryRun); // Config: transcription provider? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.UseTranscriptionProvider, Guid.Empty, this.Id, settingsTable, dryRun); message = string.Empty; return true; } private void TryProcessEnterpriseApprovedAssistantPlugins(LuaTable settingsTable, bool dryRun) { if (!ManagedConfiguration.TryGet(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, out ConfigMeta> configMeta)) return; var settingName = SettingsManager.ToSettingName>(x => x.EnterpriseApprovedPlugins); var successful = false; IList configuredApprovals = []; if (settingsTable.TryGetValue(settingName, out var configuredLuaValue) && configuredLuaValue.Type is LuaValueType.Table && configuredLuaValue.TryRead(out var approvalsTable)) { var approvals = new List(approvalsTable.ArrayLength); for (var index = 1; index <= approvalsTable.ArrayLength; index++) { var entryValue = approvalsTable[index]; if (entryValue.TryRead(out var hashText)) { var normalizedHash = NormalizeApprovalHash(hashText); if (!string.IsNullOrWhiteSpace(normalizedHash)) approvals.Add(new() { PluginHash = normalizedHash }); else LOG.LogWarning("The enterprise assistant approval entry at index {Index} contains an empty hash (config plugin id: {ConfigPluginId}).", index, this.Id); continue; } if (!entryValue.TryRead(out var entryTable)) { LOG.LogWarning("The enterprise assistant approval entry at index {Index} is neither a string nor a table (config plugin id: {ConfigPluginId}).", index, this.Id); continue; } if (!TryParseEnterpriseApprovedAssistantPlugin(index, entryTable, this.Id, out var approval)) continue; approvals.Add(approval); } configuredApprovals = approvals; successful = true; } if (dryRun) return; // // Only a configuration which speaks for an organization may approve assistant plugins: one // deployed by a configuration server, or one staged in the test directory. An approval marks // a plugin as safe without any security audit, and the user interface states that the // organization approved it. No local configuration plugin may make that claim: it would // disable the security audit for arbitrary assistant plugins while telling the user that // their organization vouched for them. // // We decide by the plugin path. The self-declared DEPLOYED_USING_CONFIG_SERVER field would // not do, because any plugin can set it to true. // if (!PluginFactory.IsOrganizationConfigurationPath(this.PluginPath)) { if (successful) LOG.LogWarning("The configuration plugin '{ConfigPluginId}' at '{PluginPath}' declares enterprise approvals for assistant plugins, but your organization's IT did not deploy it. Ignoring these approvals: only configuration plugins from a configuration server or from the test directory may approve assistant plugins.", this.Id, this.PluginPath); return; } if (PluginFactory.IsEnterpriseTestConfigurationPath(this.PluginPath)) LOG.LogWarning("The test configuration plugin '{ConfigPluginId}' at '{PluginPath}' approves assistant plugins. These approvals are valid for this session only: AI Studio empties the test directory on every start.", this.Id, this.PluginPath); switch (successful) { case true: // // Approvals of several configuration plugins add up. An approval list is a pure // allowlist over hashes: not listing a plugin already means "not approved", so // replacing the list would only ever withdraw the approvals of another // configuration without expressing anything new. // configMeta.SetPluginContribution(configuredApprovals, this.Id); // Merge into the stored list right away, so the approvals of this plugin take // effect immediately. PluginFactory.LoadAll recomputes the authoritative list once // every configuration plugin has contributed: var mergedApprovals = new List(configMeta.GetValue()); var knownHashes = mergedApprovals.Select(approval => approval.PluginHash).ToHashSet(StringComparer.Ordinal); mergedApprovals.AddRange(configuredApprovals.Where(approval => knownHashes.Add(approval.PluginHash))); configMeta.SetValue(mergedApprovals); configMeta.LockConfiguration(this.Id); break; case false when configMeta.IsLocked && configMeta.LockedByConfigPluginId == this.Id: configMeta.RemovePluginContribution(this.Id); configMeta.ResetLockedConfiguration(); break; case false: configMeta.RemovePluginContribution(this.Id); break; } } /// /// Recomputes the effective enterprise approvals from the contributions of all configuration plugins. /// /// /// Every configuration plugin merges its own approvals into the stored list while it starts, but /// nothing there can withdraw the approvals of a plugin which was removed in the meantime. This /// method rebuilds the list from the remaining contributions and is therefore called once all /// configuration plugins have been started. /// /// True when the effective approvals changed, otherwise false. public static bool RefreshEnterpriseApprovedAssistantPlugins() { if (!ManagedConfiguration.TryGet(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, out ConfigMeta> configMeta)) return false; var effectiveApprovals = new List(); var effectiveHashes = new HashSet(StringComparer.Ordinal); foreach (var approval in configMeta.PluginContributions.Values.SelectMany(contribution => contribution)) if (effectiveHashes.Add(approval.PluginHash)) effectiveApprovals.Add(approval); // Compare by hash, so a different order alone does not rewrite the settings on every start: var currentApprovals = configMeta.GetValue(); if (currentApprovals.Count == effectiveApprovals.Count && effectiveHashes.SetEquals(currentApprovals.Select(approval => approval.PluginHash))) return false; LOG.LogInformation($"The enterprise approvals for assistant plugins changed from {currentApprovals.Count} to {effectiveApprovals.Count} entries, contributed by {configMeta.PluginContributions.Count} configuration plugin(s)."); configMeta.SetValue(effectiveApprovals); return true; } private static bool TryParseEnterpriseApprovedAssistantPlugin(int index, LuaTable table, Guid configPluginId, out DataAssistantPluginEnterpriseApproval approval) { approval = new(); if (!table.TryGetValue("PluginHash", out var pluginHashValue) || !pluginHashValue.TryRead(out var pluginHash)) { LOG.LogWarning("The enterprise assistant approval entry at index {Index} is missing a valid PluginHash (config plugin id: {ConfigPluginId}).", index, configPluginId); return false; } var normalizedHash = NormalizeApprovalHash(pluginHash); if (string.IsNullOrWhiteSpace(normalizedHash)) { LOG.LogWarning("The enterprise assistant approval entry at index {Index} contains an empty PluginHash (config plugin id: {ConfigPluginId}).", index, configPluginId); return false; } var displayName = TryReadOptionalString(table, "DisplayName"); var comment = TryReadOptionalString(table, "Comment"); var approvedBy = TryReadOptionalString(table, "ApprovedBy"); var approvedAtUtc = TryReadOptionalDateTimeOffset(table, "ApprovedAtUtc", index, configPluginId); approval = new() { PluginHash = normalizedHash, DisplayName = displayName, Comment = comment, ApprovedBy = approvedBy, ApprovedAtUtc = approvedAtUtc, }; return true; } private static string TryReadOptionalString(LuaTable table, string key) { return table.TryGetValue(key, out var value) && value.TryRead(out var text) ? text : string.Empty; } private static DateTimeOffset? TryReadOptionalDateTimeOffset(LuaTable table, string key, int index, Guid configPluginId) { if (!table.TryGetValue(key, out var value)) return null; if (value.TryRead(out var text) && DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) return parsed.ToUniversalTime(); LOG.LogWarning("The enterprise assistant approval entry at index {Index} contains an invalid {Key} value (config plugin id: {ConfigPluginId}).", index, key, configPluginId); return null; } private static string NormalizeApprovalHash(string hash) => string.IsNullOrWhiteSpace(hash) ? string.Empty : hash.Trim().ToUpperInvariant(); private void TryReadMandatoryInfos(LuaTable mainTable) { if (!mainTable.TryGetValue("MANDATORY_INFOS", out var mandatoryInfosValue) || !mandatoryInfosValue.TryRead(out var mandatoryInfosTable)) return; for (var i = 1; i <= mandatoryInfosTable.ArrayLength; i++) { var luaMandatoryInfoValue = mandatoryInfosTable[i]; if (!luaMandatoryInfoValue.TryRead(out var luaMandatoryInfoTable)) { LOG.LogWarning("The table 'MANDATORY_INFOS' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", i, this.Id); continue; } if (DataMandatoryInfo.TryParseConfiguration(i, luaMandatoryInfoTable, this.Id, out var mandatoryInfo)) this.mandatoryInfos.Add(mandatoryInfo); else LOG.LogWarning("The table 'MANDATORY_INFOS' entry at index {Index} does not contain a valid mandatory info (config plugin id: {ConfigPluginId}).", i, this.Id); } } private void TryReadIntroductions(LuaTable mainTable) { if (!mainTable.TryGetValue("INTRODUCTIONS", out var introductionsValue) || !introductionsValue.TryRead(out var introductionsTable)) return; for (var i = 1; i <= introductionsTable.ArrayLength; i++) { var luaIntroductionValue = introductionsTable[i]; if (!luaIntroductionValue.TryRead(out var luaIntroductionTable)) { LOG.LogWarning("The table 'INTRODUCTIONS' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", i, this.Id); continue; } if (DataIntroduction.TryParseConfiguration(i, luaIntroductionTable, this.Id, out var introduction)) this.introductions.Add(introduction); else LOG.LogWarning("The table 'INTRODUCTIONS' entry at index {Index} does not contain a valid introduction (config plugin id: {ConfigPluginId}).", i, this.Id); } } }