Merge branch 'main' into chunk-data

Resolved 29 conflicting files. The notable decisions:

Confidence: main's tool-calling gate (RequiredProviderConfidence) and this
branch's local-RAG gate (DataConfidenceLevel) turned out to be the same rule
on the same axis, so they are now one field. Both tool results and data
sources raise it through RequireProviderConfidence(). The gate checks the
level strictly and no longer exempts providers trusted by configuration:
TrustedProviderIds is documented as applying to data-source security checks
only, and organizations set confidence through DataConfidence
.CustomConfidenceScheme instead. The security axis (DataSecurity, ERI,
IsTrustedForDataSourceSecurityChecks) is unchanged.

Provider creation: main's CreateProvider signature won (hfEndpointKind,
capabilityOverrides, no model parameter); tokenizerPath was added to it and
is set for every provider, including the new Hetzner, IONOS and LiteLLM.
Provider and EmbeddingProvider combine the record parameters, Lua parsing and
Lua serialization of both sides.

File types: main's hierarchy (ODT leaf, WORD parent, PowerPoint without the
legacy .ppt, TABULAR instead of DELIMITED_TABLE) plus this branch's
SPREADSHEET parent with ODS and the xlsm/xlsb/xla/xlam extensions, which the
runtime already reads. Both sides had added a conflicting HTML filter; the
reading family keeps the name, and the export path uses a narrow
HTML_DOCUMENT, following the existing LATEX/TEX split.

Runtime: main's file_data.rs is the base, including the prompt-injection
sanitizer and the extraction routes. Token counting and chunk segmentation
moved into take_released, so they act on the text the filter has released
rather than on text it is still holding. A failed count is logged and left
out instead of ending the extraction, because the app counts such a segment
itself.

Data sources: the participating-provider checks of this branch are kept, and
main's GetAllowedDataSources overload now builds on them. DirectChatService
resolves the launched chat's data source options before the check, so filter
and chat see the same options.

.NET and Rust both build clean; I18N regenerated to 4060 keys.
This commit is contained in:
Thorsten Sommer committed 2026-09-05 21:20:33 +02:00
commit fe35630eff
640 files changed
+45059 -4952

No files matched your search

@@ -1,4 +1,6 @@
using System.Globalization;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Services;
@@ -38,6 +40,28 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
/// True/false when explicitly configured in the plugin, otherwise null.
/// </summary>
public bool? DeployedUsingConfigServer { get; } = ReadDeployedUsingConfigServer(state);
/// <summary>
/// The priority of this configuration plugin. Defaults to zero when the plugin declares none.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public int Priority { get; } = ReadPriority(state);
/// <summary>
/// How many settings this configuration plugin declares.
/// </summary>
/// <remarks>
/// This counts the entries of the Lua SETTINGS table, without the <c>.AllowUserOverride</c>
/// 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.
/// </remarks>
public int DeclaredSettingsCount { get; private set; }
public async Task InitializeAsync(bool dryRun)
{
@@ -131,6 +155,34 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
return null;
}
private static int ReadPriority(LuaState state)
{
if (state.Environment["PRIORITY"].TryRead<int>(out var priority))
return priority;
return 0;
}
/// <summary>
/// Counts the settings a configuration plugin declares, ignoring the <c>.AllowUserOverride</c>
/// companion keys: those refine a setting instead of adding one.
/// </summary>
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<string>(out var settingName) && !settingName.EndsWith(USER_OVERRIDE_SUFFIX, StringComparison.Ordinal))
count++;
}
return count;
}
/// <summary>
/// Tries to initialize the UI text content of the plugin.
/// </summary>
@@ -156,6 +208,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
message = TB("The SETTINGS table does not exist or is not a valid table.");
return false;
}
if (!TryValidateMinimumProviderConfidenceConfiguration(settingsTable, out message))
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);
@@ -166,6 +223,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: what should be the start page?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.StartPage, this.Id, settingsTable, dryRun);
// Config: show prompt-injection alert dialogs?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowPromptInjectionAlert, 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);
@@ -181,6 +241,15 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// 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);
@@ -196,6 +265,21 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: global voice recording shortcut
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShortcutVoiceRecording, this.Id, settingsTable, dryRun);
// Config: global tool availability
ManagedConfiguration.TryProcessConfiguration(x => x.Tools, x => x.EnableTools, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.Tools, x => x.DisabledToolIds, this.Id, settingsTable, dryRun);
// Config: minimum provider confidence per tool
ManagedConfiguration.TryProcessConfiguration(x => x.Tools, x => x.MinimumProviderConfidenceByToolId, this.Id, settingsTable, dryRun);
//
// Config: settings of the individual tools, keyed by tool and field. Two tables rather
// than a property per setting, so that tools an administrator's AI Studio does not know
// at compile time — the ones plugin authors define — can be configured just the same.
//
ManagedConfiguration.TryProcessConfiguration(x => x.Tools, x => x.LockedToolSettings, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.Tools, x => x.DefaultToolSettings, this.Id, settingsTable, dryRun);
// Config: timeout for external HTTP requests
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.HttpClientTimeoutSeconds, this.Id, settingsTable, dryRun);
@@ -235,13 +319,13 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
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);
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun, this.PluginPath);
// Handle configured transcription providers:
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, x => x.NextTranscriptionNum, mainTable, this.Id, ref this.configObjects, dryRun);
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, x => x.NextTranscriptionNum, mainTable, this.Id, ref this.configObjects, dryRun, this.PluginPath);
// Handle configured embedding providers:
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, x => x.NextEmbeddingNum, mainTable, this.Id, ref this.configObjects, dryRun);
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, x => x.NextEmbeddingNum, mainTable, this.Id, ref this.configObjects, dryRun, this.PluginPath);
// Handle configured chat templates:
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, x => x.NextChatTemplateNum, mainTable, this.Id, ref this.configObjects, dryRun, this.PluginPath);
@@ -278,6 +362,30 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
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: Batch Processing Assistant defaults?
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.PreselectOptions, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.InputDirectory, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.OutputDirectory, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.FilePatterns, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.IncludeSubdirectories, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.PromptSource, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.FreePrompt, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.PromptFilePath, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.PreselectedPolicyId, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.OutputMode, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.ResultFileFormat, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.CsvFileName, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.ResultColumnHeader, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.CsvSeparator, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.CustomCsvSeparator, this.Id, settingsTable, dryRun);
var minimumDelayIsValid = ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.MinimumDelaySeconds, this.Id, settingsTable, dryRun, validator: value => value is >= DataBatchProcessing.MIN_DELAY_SECONDS and <= DataBatchProcessing.MAX_DELAY_SECONDS);
if (!minimumDelayIsValid && settingsTable.TryGetValue("DataBatchProcessing.MinimumDelaySeconds", out _))
LOG.LogWarning("The Batch Processing minimum delay configured by plugin {ConfigPluginId} must be between {MinimumDelaySeconds} and {MaximumDelaySeconds} seconds.", this.Id, DataBatchProcessing.MIN_DELAY_SECONDS, DataBatchProcessing.MAX_DELAY_SECONDS);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.MinimumProviderConfidence, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun);
// Config: transcription provider?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.UseTranscriptionProvider, Guid.Empty, this.Id, settingsTable, dryRun);
@@ -285,6 +393,37 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
return true;
}
private static bool TryValidateMinimumProviderConfidenceConfiguration(LuaTable settingsTable, out string message)
{
const string SETTING_NAME = "DataTools.MinimumProviderConfidenceByToolId";
message = string.Empty;
if (!settingsTable.TryGetValue(SETTING_NAME, out var configuredValue))
return true;
if (configuredValue.Type is not LuaValueType.Table || !configuredValue.TryRead<LuaTable>(out var configuredTable))
{
message = $"The setting '{SETTING_NAME}' must be a table of tool IDs and confidence levels.";
return false;
}
var previousKey = LuaValue.Nil;
while (configuredTable.TryGetNext(previousKey, out var pair))
{
previousKey = pair.Key;
if (!pair.Key.TryRead<string>(out var toolId) || string.IsNullOrWhiteSpace(toolId) ||
!pair.Value.TryRead<string>(out var configuredLevel) ||
!Enum.TryParse<ConfidenceLevel>(configuredLevel, true, out var confidenceLevel) ||
!Enum.IsDefined(confidenceLevel) ||
confidenceLevel is ConfidenceLevel.UNKNOWN)
{
message = $"The setting '{SETTING_NAME}' contains an invalid tool ID or confidence level. Allowed confidence levels are NONE, UNTRUSTED, VERY_LOW, LOW, MODERATE, MEDIUM, and HIGH.";
return false;
}
}
return true;
}
private void TryProcessEnterpriseApprovedAssistantPlugins(LuaTable settingsTable, bool dryRun)
{
if (!ManagedConfiguration.TryGet(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, out ConfigMeta<DataAssistantPluginAudit, IList<DataAssistantPluginEnterpriseApproval>> configMeta))
@@ -325,26 +464,199 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
approvals.Add(approval);
}
configuredApprovals = approvals;
// A configuration may list the same hash more than once, e.g. once to describe the
// plugin and once to activate it. Combine those before anything else sees them:
configuredApprovals = CombineApprovals(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:
configMeta.SetValue(configuredApprovals);
//
// 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:
configMeta.SetValue(CombineApprovals(configMeta.GetValue().Concat(configuredApprovals)));
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;
}
}
/// <summary>
/// Recomputes the effective enterprise approvals from the contributions of all configuration plugins.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <returns>True when the effective approvals changed, otherwise false.</returns>
public static bool RefreshEnterpriseApprovedAssistantPlugins()
{
if (!ManagedConfiguration.TryGet(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, out ConfigMeta<DataAssistantPluginAudit, IList<DataAssistantPluginEnterpriseApproval>> configMeta))
return false;
var effectiveApprovals = CombineApprovals(configMeta.PluginContributions.Values.SelectMany(contribution => contribution));
// Compare by what an approval decides, so a different order alone does not rewrite the
// settings on every start, while a changed activation does reach the user:
var currentApprovals = configMeta.GetValue();
if (HaveApprovalsSameEffect(currentApprovals, effectiveApprovals))
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;
}
/// <summary>
/// Reduces approvals of several configuration plugins to one entry per assistant plugin hash.
/// </summary>
/// <remarks>
/// Approving the same plugin twice is normal: a base configuration approves it for the whole
/// organization, and a department configuration lists it again to activate it. Keeping only the
/// entry seen first would silently drop what the other one asked for, and the contributions
/// carry no guaranteed order, so which one that is could differ from start to start.
/// </remarks>
/// <param name="approvals">The approvals of all configuration plugins, in any order.</param>
/// <returns>One approval per hash, in the order the hashes were first seen.</returns>
private static List<DataAssistantPluginEnterpriseApproval> CombineApprovals(IEnumerable<DataAssistantPluginEnterpriseApproval> approvals)
{
var combined = new List<DataAssistantPluginEnterpriseApproval>();
var positionByHash = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var approval in approvals)
{
if (positionByHash.TryGetValue(approval.PluginHash, out var position))
{
combined[position] = MergeApprovals(combined[position], approval);
continue;
}
positionByHash[approval.PluginHash] = combined.Count;
combined.Add(approval);
}
return combined;
}
/// <summary>
/// Combines two approvals of the same assistant plugin hash into a single one.
/// </summary>
/// <remarks>
/// The two activation fields are combined in opposite directions on purpose. One configuration
/// asking for the activation is enough to activate, because not asking for it says nothing
/// against it. The freedom to switch the assistant off again, however, only survives when every
/// configuration which does ask for the activation grants it: otherwise a department could take
/// back a lock the organization deliberately set. An approval which does not ask for the
/// activation at all expresses nothing about that freedom and is therefore not counted.<br/><br/>
/// The result of these two fields does not depend on the order the approvals arrive in. For the
/// descriptive fields, the first value which says anything wins, and the approval date is the
/// earliest one given: the plugin has been approved since then.
/// </remarks>
/// <param name="first">The approval seen first.</param>
/// <param name="second">The approval to combine it with.</param>
/// <returns>The combined approval.</returns>
private static DataAssistantPluginEnterpriseApproval MergeApprovals(DataAssistantPluginEnterpriseApproval first, DataAssistantPluginEnterpriseApproval second) => new()
{
PluginHash = first.PluginHash,
DisplayName = string.IsNullOrWhiteSpace(first.DisplayName) ? second.DisplayName : first.DisplayName,
Comment = string.IsNullOrWhiteSpace(first.Comment) ? second.Comment : first.Comment,
ApprovedBy = string.IsNullOrWhiteSpace(first.ApprovedBy) ? second.ApprovedBy : first.ApprovedBy,
ApprovedAtUtc = EarliestApprovalTime(first.ApprovedAtUtc, second.ApprovedAtUtc),
Activate = first.Activate || second.Activate,
AllowUserOverride = (first.Activate, second.Activate) switch
{
(true, true) => first.AllowUserOverride && second.AllowUserOverride,
(true, false) => first.AllowUserOverride,
(false, true) => second.AllowUserOverride,
_ => false,
},
};
private static DateTimeOffset? EarliestApprovalTime(DateTimeOffset? first, DateTimeOffset? second) => (first, second) switch
{
(null, _) => second,
(_, null) => first,
_ => first <= second ? first : second,
};
/// <summary>
/// Checks whether two approval lists decide the same thing for every assistant plugin.
/// </summary>
/// <remarks>
/// This is what tells a rewrite of the settings apart from a mere reordering of the same
/// approvals. Only the hash and the two activation fields are compared: the descriptive fields
/// change nothing about what an approval does, and rewriting the settings because a comment was
/// reworded would store the file on every start.
/// </remarks>
/// <param name="currentApprovals">The approvals currently stored in the settings.</param>
/// <param name="effectiveApprovals">The approvals recomputed from the contributions.</param>
/// <returns>True when both lists have the same effect, otherwise false.</returns>
private static bool HaveApprovalsSameEffect(IList<DataAssistantPluginEnterpriseApproval> currentApprovals, IList<DataAssistantPluginEnterpriseApproval> effectiveApprovals)
{
if (currentApprovals.Count != effectiveApprovals.Count)
return false;
var currentByHash = new Dictionary<string, DataAssistantPluginEnterpriseApproval>(StringComparer.Ordinal);
foreach (var approval in currentApprovals)
currentByHash[approval.PluginHash] = approval;
foreach (var effectiveApproval in effectiveApprovals)
{
if (!currentByHash.TryGetValue(effectiveApproval.PluginHash, out var currentApproval))
return false;
if (currentApproval.Activate != effectiveApproval.Activate || currentApproval.AllowUserOverride != effectiveApproval.AllowUserOverride)
return false;
}
return true;
}
private static bool TryParseEnterpriseApprovedAssistantPlugin(int index, LuaTable table, Guid configPluginId, out DataAssistantPluginEnterpriseApproval approval)
{
approval = new();
@@ -366,6 +678,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
var comment = TryReadOptionalString(table, "Comment");
var approvedBy = TryReadOptionalString(table, "ApprovedBy");
var approvedAtUtc = TryReadOptionalDateTimeOffset(table, "ApprovedAtUtc", index, configPluginId);
var activate = TryReadOptionalBool(table, "Activate", index, configPluginId);
var allowUserOverride = TryReadOptionalBool(table, "AllowUserOverride", index, configPluginId);
if (allowUserOverride && !activate)
LOG.LogWarning("The enterprise assistant approval entry at index {Index} allows the user to override an activation it never asks for. 'AllowUserOverride' has no effect without 'Activate' (config plugin id: {ConfigPluginId}).", index, configPluginId);
approval = new()
{
@@ -374,6 +691,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
Comment = comment,
ApprovedBy = approvedBy,
ApprovedAtUtc = approvedAtUtc,
Activate = activate,
AllowUserOverride = allowUserOverride,
};
return true;
}
@@ -385,6 +704,18 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
: string.Empty;
}
private static bool TryReadOptionalBool(LuaTable table, string key, int index, Guid configPluginId)
{
if (!table.TryGetValue(key, out var value))
return false;
if (value.TryRead<bool>(out var flag))
return flag;
LOG.LogWarning("The enterprise assistant approval entry at index {Index} contains an invalid {Key} value. Expected a boolean (config plugin id: {ConfigPluginId}).", index, key, configPluginId);
return false;
}
private static DateTimeOffset? TryReadOptionalDateTimeOffset(LuaTable table, string key, int index, Guid configPluginId)
{
if (!table.TryGetValue(key, out var value))